"""Tests for core/context_builder.py — context assembly per role.""" import pytest from core.db import init_db from core import models from core.context_builder import build_context, format_prompt @pytest.fixture def conn(): c = init_db(":memory:") # Seed project, modules, decisions, tasks models.create_project(c, "vdol", "ВДОЛЬ и ПОПЕРЕК", "~/projects/vdolipoperek", tech_stack=["vue3", "typescript", "nodejs"]) models.add_module(c, "vdol", "search", "frontend", "src/search/") models.add_module(c, "vdol", "api", "backend", "src/api/") models.add_decision(c, "vdol", "gotcha", "Safari bug", "position:fixed breaks", category="ui", tags=["ios"]) models.add_decision(c, "vdol", "workaround", "API rate limit", "10 req/s max", category="api") models.add_decision(c, "vdol", "convention", "Use WAL mode", "Always use WAL for SQLite", category="architecture") models.add_decision(c, "vdol", "decision", "Auth required", "All endpoints need auth", category="security") models.create_task(c, "VDOL-001", "vdol", "Fix search filters", brief={"module": "search", "route_type": "debug"}) models.create_task(c, "VDOL-002", "vdol", "Add payments", status="in_progress") yield c c.close() class TestBuildContext: def test_pm_gets_everything(self, conn): ctx = build_context(conn, "VDOL-001", "pm", "vdol") assert ctx["task"]["id"] == "VDOL-001" assert ctx["project"]["id"] == "vdol" assert len(ctx["modules"]) == 2 assert len(ctx["decisions"]) == 4 # all decisions assert len(ctx["active_tasks"]) == 1 # VDOL-002 in_progress assert "pm" in ctx["available_specialists"] def test_architect_gets_all_decisions_and_modules(self, conn): ctx = build_context(conn, "VDOL-001", "architect", "vdol") assert len(ctx["modules"]) == 2 assert len(ctx["decisions"]) == 4 def test_debugger_gets_only_gotcha_workaround(self, conn): ctx = build_context(conn, "VDOL-001", "debugger", "vdol") types = {d["type"] for d in ctx["decisions"]} assert types <= {"gotcha", "workaround"} assert "convention" not in types assert "decision" not in types assert ctx["module_hint"] == "search" def test_frontend_dev_gets_gotcha_workaround_convention(self, conn): ctx = build_context(conn, "VDOL-001", "frontend_dev", "vdol") types = {d["type"] for d in ctx["decisions"]} assert "gotcha" in types assert "workaround" in types assert "convention" in types assert "decision" not in types # plain decisions excluded def test_backend_dev_same_as_frontend(self, conn): ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") types = {d["type"] for d in ctx["decisions"]} assert types == {"gotcha", "workaround", "convention"} def test_reviewer_gets_only_conventions(self, conn): ctx = build_context(conn, "VDOL-001", "reviewer", "vdol") types = {d["type"] for d in ctx["decisions"]} assert types == {"convention"} def test_tester_gets_minimal_context(self, conn): ctx = build_context(conn, "VDOL-001", "tester", "vdol") assert ctx["task"] is not None assert ctx["project"] is not None assert "decisions" not in ctx assert "modules" not in ctx def test_security_gets_security_decisions(self, conn): ctx = build_context(conn, "VDOL-001", "security", "vdol") categories = {d.get("category") for d in ctx["decisions"]} assert categories == {"security"} def test_unknown_role_gets_fallback(self, conn): ctx = build_context(conn, "VDOL-001", "unknown_role", "vdol") assert "decisions" in ctx assert len(ctx["decisions"]) > 0 class TestFormatPrompt: def test_format_with_template(self, conn): ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "You are a debugger. Find bugs.") assert "You are a debugger" in prompt assert "VDOL-001" in prompt assert "Fix search filters" in prompt assert "vdol" in prompt assert "vue3" in prompt def test_format_includes_decisions(self, conn): ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "Debug this.") assert "Safari bug" in prompt assert "API rate limit" in prompt # Convention should NOT be here (debugger doesn't get it) assert "WAL mode" not in prompt def test_format_pm_includes_specialists(self, conn): ctx = build_context(conn, "VDOL-001", "pm", "vdol") prompt = format_prompt(ctx, "pm", "You are PM.") assert "Available specialists" in prompt assert "debugger" in prompt assert "Active tasks" in prompt assert "VDOL-002" in prompt def test_format_with_previous_output(self, conn): ctx = build_context(conn, "VDOL-001", "tester", "vdol") ctx["previous_output"] = "Found race condition in useSearch.ts" prompt = format_prompt(ctx, "tester", "Write tests.") assert "Previous step output" in prompt assert "race condition" in prompt def test_format_loads_prompt_file(self, conn): ctx = build_context(conn, "VDOL-001", "pm", "vdol") prompt = format_prompt(ctx, "pm") # Should load from agents/prompts/pm.md assert "decompose" in prompt.lower() or "pipeline" in prompt.lower() def test_format_missing_prompt_file(self, conn): ctx = build_context(conn, "VDOL-001", "analyst", "vdol") prompt = format_prompt(ctx, "analyst") # No analyst.md exists assert "analyst" in prompt.lower() def test_format_includes_language_ru(self, conn): ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "Debug.") assert "## Language" in prompt assert "Russian" in prompt assert "ALWAYS respond in Russian" in prompt def test_format_includes_language_en(self, conn): # Update project language to en conn.execute("UPDATE projects SET language='en' WHERE id='vdol'") conn.commit() ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "Debug.") assert "ALWAYS respond in English" in prompt class TestLanguageInProject: def test_project_has_language_default(self, conn): p = models.get_project(conn, "vdol") assert p["language"] == "ru" def test_create_project_with_language(self, conn): p = models.create_project(conn, "en-proj", "English Project", "/en", language="en") assert p["language"] == "en" def test_context_carries_language(self, conn): ctx = build_context(conn, "VDOL-001", "pm", "vdol") assert ctx["project"]["language"] == "ru" # --------------------------------------------------------------------------- # KIN-045: Revise context — revise_comment + last agent output injection # --------------------------------------------------------------------------- class TestReviseContext: """build_context и format_prompt корректно инжектируют контекст ревизии.""" def test_build_context_includes_revise_comment_in_task(self, conn): """Если у задачи есть revise_comment, он попадает в ctx['task'].""" conn.execute( "UPDATE tasks SET revise_comment=? WHERE id='VDOL-001'", ("Доисследуй edge case с пустым массивом",), ) conn.commit() ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") assert ctx["task"]["revise_comment"] == "Доисследуй edge case с пустым массивом" def test_build_context_fetches_last_agent_output_when_revise_comment_set(self, conn): """При revise_comment build_context достаёт last_agent_output из agent_logs.""" from core import models models.log_agent_run( conn, "vdol", "developer", "execute", task_id="VDOL-001", output_summary="Реализован endpoint POST /api/items", success=True, ) conn.execute( "UPDATE tasks SET revise_comment=? WHERE id='VDOL-001'", ("Добавь валидацию входных данных",), ) conn.commit() ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") assert ctx.get("last_agent_output") == "Реализован endpoint POST /api/items" def test_build_context_no_last_agent_output_when_no_successful_logs(self, conn): """revise_comment есть, но нет успешных логов — last_agent_output отсутствует.""" from core import models models.log_agent_run( conn, "vdol", "developer", "execute", task_id="VDOL-001", output_summary="Permission denied", success=False, ) conn.execute( "UPDATE tasks SET revise_comment=? WHERE id='VDOL-001'", ("Повтори без ошибок",), ) conn.commit() ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") assert "last_agent_output" not in ctx def test_build_context_no_revise_fields_when_no_revise_comment(self, conn): """Обычная задача без revise_comment не получает last_agent_output в контексте.""" from core import models models.log_agent_run( conn, "vdol", "developer", "execute", task_id="VDOL-001", output_summary="Всё готово", success=True, ) # revise_comment не устанавливаем ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") assert "last_agent_output" not in ctx assert ctx["task"].get("revise_comment") is None def test_format_prompt_includes_director_revision_request(self, conn): """format_prompt содержит секцию '## Director's revision request:' при revise_comment.""" conn.execute( "UPDATE tasks SET revise_comment=? WHERE id='VDOL-001'", ("Обработай случай пустого списка",), ) conn.commit() ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") prompt = format_prompt(ctx, "backend_dev", "You are a developer.") assert "## Director's revision request:" in prompt assert "Обработай случай пустого списка" in prompt def test_format_prompt_includes_previous_output_before_revision(self, conn): """format_prompt содержит '## Your previous output (before revision):' при last_agent_output.""" from core import models models.log_agent_run( conn, "vdol", "developer", "execute", task_id="VDOL-001", output_summary="Сделал миграцию БД", success=True, ) conn.execute( "UPDATE tasks SET revise_comment=? WHERE id='VDOL-001'", ("Ещё добавь индекс",), ) conn.commit() ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") prompt = format_prompt(ctx, "backend_dev", "You are a developer.") assert "## Your previous output (before revision):" in prompt assert "Сделал миграцию БД" in prompt def test_format_prompt_no_revision_sections_when_no_revise_comment(self, conn): """Без revise_comment в prompt нет секций ревизии.""" ctx = build_context(conn, "VDOL-001", "backend_dev", "vdol") prompt = format_prompt(ctx, "backend_dev", "You are a developer.") assert "## Director's revision request:" not in prompt assert "## Your previous output (before revision):" not in prompt # --------------------------------------------------------------------------- # KIN-071: project_type and SSH context # --------------------------------------------------------------------------- class TestOperationsProject: """KIN-071: operations project_type propagates to context and prompt.""" @pytest.fixture def ops_conn(self): c = init_db(":memory:") models.create_project( c, "srv", "My Server", "", project_type="operations", ssh_host="10.0.0.1", ssh_user="root", ssh_key_path="~/.ssh/id_rsa", ssh_proxy_jump="jumpt", ) models.create_task(c, "SRV-001", "srv", "Scan server") yield c c.close() def test_slim_project_includes_project_type(self, ops_conn): """KIN-071: _slim_project включает project_type.""" ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") assert ctx["project"]["project_type"] == "operations" def test_slim_project_includes_ssh_fields_for_operations(self, ops_conn): """KIN-071: _slim_project включает ssh_* поля для operations-проектов.""" ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") proj = ctx["project"] assert proj["ssh_host"] == "10.0.0.1" assert proj["ssh_user"] == "root" assert proj["ssh_key_path"] == "~/.ssh/id_rsa" assert proj["ssh_proxy_jump"] == "jumpt" def test_slim_project_no_ssh_fields_for_development(self): """KIN-071: development-проект не получает ssh_* в slim.""" c = init_db(":memory:") models.create_project(c, "dev", "Dev", "/path") models.create_task(c, "DEV-001", "dev", "A task") ctx = build_context(c, "DEV-001", "backend_dev", "dev") assert "ssh_host" not in ctx["project"] c.close() def test_sysadmin_context_gets_decisions_and_modules(self, ops_conn): """KIN-071: sysadmin роль получает все decisions и modules.""" models.add_module(ops_conn, "srv", "nginx", "service", "/etc/nginx") models.add_decision(ops_conn, "srv", "gotcha", "Port 80 in use", "conflict") ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") assert "decisions" in ctx assert "modules" in ctx assert len(ctx["modules"]) == 1 def test_format_prompt_includes_ssh_connection_section(self, ops_conn): """KIN-071: format_prompt добавляет '## SSH Connection' для operations.""" ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.") assert "## SSH Connection" in prompt assert "10.0.0.1" in prompt assert "root" in prompt assert "jumpt" in prompt def test_format_prompt_no_ssh_section_for_development(self): """KIN-071: development-проект не получает SSH-секцию в prompt.""" c = init_db(":memory:") models.create_project(c, "dev", "Dev", "/path") models.create_task(c, "DEV-001", "dev", "A task") ctx = build_context(c, "DEV-001", "backend_dev", "dev") prompt = format_prompt(ctx, "backend_dev", "You are a dev.") assert "## SSH Connection" not in prompt c.close() def test_format_prompt_includes_project_type(self, ops_conn): """KIN-071: format_prompt включает Project type в секцию проекта.""" ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.") assert "Project type: operations" in prompt # --------------------------------------------------------------------------- # KIN-071: PM routing — operations project routes PM to infra_* pipelines # --------------------------------------------------------------------------- class TestPMRoutingOperations: """PM-контекст для operations-проекта должен содержать infra-маршруты, не включающие architect/frontend_dev.""" @pytest.fixture def ops_conn(self): c = init_db(":memory:") models.create_project( c, "srv", "My Server", "", project_type="operations", ssh_host="10.0.0.1", ssh_user="root", ) models.create_task(c, "SRV-001", "srv", "Scan server") yield c c.close() def test_pm_context_has_operations_project_type(self, ops_conn): """PM получает project_type=operations в контексте проекта.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") assert ctx["project"]["project_type"] == "operations" def test_pm_context_has_infra_scan_route(self, ops_conn): """PM-контекст содержит маршрут infra_scan из specialists.yaml.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") assert "infra_scan" in ctx["routes"] def test_pm_context_has_infra_debug_route(self, ops_conn): """PM-контекст содержит маршрут infra_debug из specialists.yaml.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") assert "infra_debug" in ctx["routes"] def test_infra_scan_route_uses_sysadmin(self, ops_conn): """infra_scan маршрут включает sysadmin в шагах.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") steps = ctx["routes"]["infra_scan"]["steps"] assert "sysadmin" in steps def test_infra_scan_route_excludes_architect(self, ops_conn): """infra_scan маршрут не назначает architect.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") steps = ctx["routes"]["infra_scan"]["steps"] assert "architect" not in steps def test_infra_scan_route_excludes_frontend_dev(self, ops_conn): """infra_scan маршрут не назначает frontend_dev.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") steps = ctx["routes"]["infra_scan"]["steps"] assert "frontend_dev" not in steps def test_format_prompt_pm_operations_project_type_label(self, ops_conn): """format_prompt для PM с operations-проектом содержит 'Project type: operations'.""" ctx = build_context(ops_conn, "SRV-001", "pm", "srv") prompt = format_prompt(ctx, "pm", "You are PM.") assert "Project type: operations" in prompt # --------------------------------------------------------------------------- # KIN-090: Attachments — context builder includes attachment paths # --------------------------------------------------------------------------- class TestAttachmentsInContext: """KIN-090: AC2 — агенты получают пути к вложениям в контексте задачи.""" @pytest.fixture def conn_with_attachments(self): c = init_db(":memory:") models.create_project(c, "prj", "Project", "/tmp/prj") models.create_task(c, "PRJ-001", "prj", "Fix bug") models.create_attachment( c, "PRJ-001", "screenshot.png", "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png", "image/png", 1024, ) models.create_attachment( c, "PRJ-001", "mockup.jpg", "/tmp/prj/.kin/attachments/PRJ-001/mockup.jpg", "image/jpeg", 2048, ) yield c c.close() def test_build_context_includes_attachments(self, conn_with_attachments): """KIN-090: AC2 — build_context включает вложения в контекст для всех ролей.""" ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj") assert "attachments" in ctx assert len(ctx["attachments"]) == 2 def test_build_context_attachments_have_filename_and_path(self, conn_with_attachments): """KIN-090: вложения в контексте содержат filename и path.""" ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj") filenames = {a["filename"] for a in ctx["attachments"]} paths = {a["path"] for a in ctx["attachments"]} assert "screenshot.png" in filenames assert "mockup.jpg" in filenames assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths def test_build_context_no_attachments_key_when_empty(self, conn): """KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет.""" # conn fixture has no attachments ctx = build_context(conn, "VDOL-001", "debugger", "vdol") assert "attachments" not in ctx def test_all_roles_get_attachments(self, conn_with_attachments): """KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения.""" for role in ("debugger", "pm", "tester", "reviewer", "backend_dev", "frontend_dev"): ctx = build_context(conn_with_attachments, "PRJ-001", role, "prj") assert "attachments" in ctx, f"Role '{role}' did not receive attachments" def test_format_prompt_includes_attachments_section(self, conn_with_attachments): """KIN-090: format_prompt включает секцию '## Attachments' с именами и путями.""" ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj") prompt = format_prompt(ctx, "debugger", "You are a debugger.") assert "## Attachments" in prompt assert "screenshot.png" in prompt assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in prompt def test_format_prompt_no_attachments_section_when_none(self, conn): """KIN-090: format_prompt не добавляет секцию вложений, если их нет.""" ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "Debug this.") assert "## Attachments" not in prompt