Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
|
"""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()
|
2026-03-15 14:39:33 +02:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-03-16 07:21:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|