Compare commits

..

3 commits

Author SHA1 Message Date
Gros Frumos
5750b72e8b kin: auto-commit after pipeline 2026-03-19 20:35:50 +02:00
Gros Frumos
39b6f38414 Merge branch 'KIN-DOCS-004-backend_dev' 2026-03-19 20:30:50 +02:00
Gros Frumos
ae2f0f1c81 kin: KIN-DOCS-004-backend_dev 2026-03-19 20:30:50 +02:00
9 changed files with 535 additions and 12 deletions

View file

@ -15,6 +15,7 @@ You receive:
**Normal mode** (default): **Normal mode** (default):
0. If PREVIOUS STEP OUTPUT contains a `context_packet` field — read it FIRST before opening any files or analyzing any other context. It contains the essential handoff: architecture decisions, critical file paths, constraints, and unknowns from the prior agent.
1. Read `DESIGN.md`, `core/models.py`, `core/db.py`, `agents/runner.py`, and any MODULES files relevant to the task 1. Read `DESIGN.md`, `core/models.py`, `core/db.py`, `agents/runner.py`, and any MODULES files relevant to the task
2. Understand the current architecture — what already exists and what needs to change 2. Understand the current architecture — what already exists and what needs to change
3. Design the solution: data model, interfaces, component interactions 3. Design the solution: data model, interfaces, component interactions

View file

@ -12,6 +12,7 @@ You receive:
## Working Mode ## Working Mode
0. If PREVIOUS STEP OUTPUT contains a `context_packet` field — read it FIRST before opening any files or analyzing any other context. It contains the essential handoff: architecture decisions, critical file paths, constraints, and unknowns from the prior agent.
1. Read all relevant backend files before making any changes 1. Read all relevant backend files before making any changes
2. Review `PREVIOUS STEP OUTPUT` if it contains an architect spec — follow it precisely 2. Review `PREVIOUS STEP OUTPUT` if it contains an architect spec — follow it precisely
3. Implement the feature or fix as described in the task brief 3. Implement the feature or fix as described in the task brief

View file

@ -28,6 +28,7 @@ You receive:
- Handoff notes clarity — the next department must be able to start without asking questions - Handoff notes clarity — the next department must be able to start without asking questions
- Previous department handoff — build on their work, don't repeat it - Previous department handoff — build on their work, don't repeat it
- Sub-pipeline length — keep it SHORT, 1-4 steps maximum - Sub-pipeline length — keep it SHORT, 1-4 steps maximum
- Produce a `context_packet` with exactly 5 fields: `architecture_notes` (string — key arch decisions made), `key_files` (array of file paths critical for the next agent), `constraints` (array of hard technical/business limits discovered), `unknowns` (array of open risks or unresolved questions), `handoff_for` (string — role name of the first worker in sub_pipeline). In the brief for the FIRST worker in sub_pipeline, include this sentence verbatim: «IMPORTANT: Read the `context_packet` field in PREVIOUS STEP OUTPUT FIRST, before any other section or file.»
**Department-specific guidance:** **Department-specific guidance:**
@ -46,6 +47,7 @@ You receive:
- Each worker brief is self-contained — no "see above" references - Each worker brief is self-contained — no "see above" references
- Artifacts list is complete and specific - Artifacts list is complete and specific
- Handoff notes are actionable for the next department - Handoff notes are actionable for the next department
- `context_packet` is present with all 5 required fields; `handoff_for` is non-empty and matches the role in `sub_pipeline[0]`; first worker brief contains explicit instruction to read `context_packet` first
## Return Format ## Return Format
@ -72,7 +74,14 @@ Return ONLY valid JSON (no markdown, no explanation):
"schemas": [], "schemas": [],
"notes": "Added feature with full test coverage. All tests pass." "notes": "Added feature with full test coverage. All tests pass."
}, },
"handoff_notes": "Backend implementation complete. Tests passing. Frontend needs to call POST /api/feature with {field: value} body." "handoff_notes": "Backend implementation complete. Tests passing. Frontend needs to call POST /api/feature with {field: value} body.",
"context_packet": {
"architecture_notes": "Used existing models.py pattern, no ORM, raw sqlite3",
"key_files": ["core/models.py", "web/api.py"],
"constraints": ["All DB columns must have DEFAULT values", "No new Python deps"],
"unknowns": ["Frontend integration not yet verified"],
"handoff_for": "backend_dev"
}
} }
``` ```

View file

@ -12,6 +12,7 @@ You receive:
## Working Mode ## Working Mode
0. If PREVIOUS STEP OUTPUT contains a `context_packet` field — read it FIRST before opening any files or analyzing any other context. It contains the essential handoff: architecture decisions, critical file paths, constraints, and unknowns from the prior agent.
1. Read all relevant frontend files before making any changes 1. Read all relevant frontend files before making any changes
2. Review `PREVIOUS STEP OUTPUT` if it contains an architect spec — follow it precisely 2. Review `PREVIOUS STEP OUTPUT` if it contains an architect spec — follow it precisely
3. Implement the feature or fix as described in the task brief 3. Implement the feature or fix as described in the task brief

View file

@ -1347,14 +1347,29 @@ def _execute_department_head_step(
role = step["role"] role = step["role"]
dept_name = role.replace("_head", "") dept_name = role.replace("_head", "")
# Build initial context for workers: dept head's plan + artifacts # Extract context_packet (KIN-DOCS-004): fail-open if missing
dept_plan_context = json.dumps({ context_packet = parsed.get("context_packet")
"department_head_plan": { if context_packet is None and parent_pipeline_id:
try:
models.write_log(
conn, parent_pipeline_id,
f"Dept {step['role']}: context_packet missing from output — handoff quality degraded",
level="WARN",
extra={"role": step["role"]},
)
except Exception:
pass
# Build initial context for workers: context_packet first, then dept head's plan
dept_plan_context_dict: dict = {}
if context_packet is not None:
dept_plan_context_dict["context_packet"] = context_packet
dept_plan_context_dict["department_head_plan"] = {
"department": dept_name, "department": dept_name,
"artifacts": parsed.get("artifacts", {}), "artifacts": parsed.get("artifacts", {}),
"handoff_notes": parsed.get("handoff_notes", ""), "handoff_notes": parsed.get("handoff_notes", ""),
}, }
}, ensure_ascii=False) dept_plan_context = json.dumps(dept_plan_context_dict, ensure_ascii=False)
# KIN-084: log sub-pipeline start # KIN-084: log sub-pipeline start
if parent_pipeline_id: if parent_pipeline_id:
@ -1429,6 +1444,7 @@ def _execute_department_head_step(
decisions_made=decisions_made, decisions_made=decisions_made,
blockers=[], blockers=[],
status=handoff_status, status=handoff_status,
context_packet=context_packet,
) )
except Exception: except Exception:
pass # Handoff save errors must never block pipeline pass # Handoff save errors must never block pipeline
@ -1438,6 +1454,7 @@ def _execute_department_head_step(
"from_department": dept_name, "from_department": dept_name,
"handoff_notes": parsed.get("handoff_notes", ""), "handoff_notes": parsed.get("handoff_notes", ""),
"artifacts": parsed.get("artifacts", {}), "artifacts": parsed.get("artifacts", {}),
"context_packet": context_packet,
"sub_pipeline_summary": { "sub_pipeline_summary": {
"steps_completed": sub_result.get("steps_completed", 0), "steps_completed": sub_result.get("steps_completed", 0),
"success": sub_result.get("success", False), "success": sub_result.get("success", False),

View file

@ -181,6 +181,8 @@ specialists:
context_rules: context_rules:
decisions: all decisions: all
modules: all modules: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
frontend_head: frontend_head:
name: "Frontend Department Head" name: "Frontend Department Head"
@ -193,6 +195,8 @@ specialists:
context_rules: context_rules:
decisions: all decisions: all
modules: all modules: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
qa_head: qa_head:
name: "QA Department Head" name: "QA Department Head"
@ -204,6 +208,8 @@ specialists:
permissions: read_only permissions: read_only
context_rules: context_rules:
decisions: all decisions: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
security_head: security_head:
name: "Security Department Head" name: "Security Department Head"
@ -215,6 +221,8 @@ specialists:
permissions: read_only permissions: read_only
context_rules: context_rules:
decisions_category: security decisions_category: security
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
infra_head: infra_head:
name: "Infrastructure Department Head" name: "Infrastructure Department Head"
@ -226,6 +234,8 @@ specialists:
permissions: read_only permissions: read_only
context_rules: context_rules:
decisions: all decisions: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
knowledge_synthesizer: knowledge_synthesizer:
name: "Knowledge Synthesizer" name: "Knowledge Synthesizer"
@ -252,6 +262,8 @@ specialists:
permissions: read_only permissions: read_only
context_rules: context_rules:
decisions: all decisions: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
marketing_head: marketing_head:
name: "Marketing Department Head" name: "Marketing Department Head"
@ -264,6 +276,8 @@ specialists:
context_rules: context_rules:
decisions: all decisions: all
modules: all modules: all
output_schema:
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
# Departments — PM uses these when routing complex cross-domain tasks to department heads # Departments — PM uses these when routing complex cross-domain tasks to department heads
departments: departments:

View file

@ -165,6 +165,7 @@ CREATE TABLE IF NOT EXISTS department_handoffs (
artifacts JSON, artifacts JSON,
decisions_made JSON, decisions_made JSON,
blockers JSON, blockers JSON,
context_packet JSON DEFAULT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@ -703,6 +704,13 @@ def _migrate(conn: sqlite3.Connection):
""") """)
conn.commit() conn.commit()
# Add context_packet column to department_handoffs (KIN-DOCS-004)
if "department_handoffs" in existing_tables:
handoff_cols = {r[1] for r in conn.execute("PRAGMA table_info(department_handoffs)").fetchall()}
if "context_packet" not in handoff_cols:
conn.execute("ALTER TABLE department_handoffs ADD COLUMN context_packet JSON DEFAULT NULL")
conn.commit()
# Add test_command column to projects (KIN-ARCH-008); NULL = auto-detect (KIN-101) # Add test_command column to projects (KIN-ARCH-008); NULL = auto-detect (KIN-101)
projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
if "test_command" not in projects_cols: if "test_command" not in projects_cols:

View file

@ -42,6 +42,7 @@ _JSON_COLUMNS: frozenset[str] = frozenset({
"dependencies", "dependencies",
"steps", "steps",
"artifacts", "decisions_made", "blockers", "artifacts", "decisions_made", "blockers",
"context_packet",
"extra_json", "extra_json",
"pending_actions", "pending_actions",
}) })
@ -1191,14 +1192,16 @@ def create_handoff(
decisions_made: list | None = None, decisions_made: list | None = None,
blockers: list | None = None, blockers: list | None = None,
status: str = "pending", status: str = "pending",
context_packet: dict | list | None = None,
) -> dict: ) -> dict:
"""Record a department handoff with artifacts for inter-department context.""" """Record a department handoff with artifacts for inter-department context."""
cur = conn.execute( cur = conn.execute(
"""INSERT INTO department_handoffs """INSERT INTO department_handoffs
(pipeline_id, task_id, from_department, to_department, artifacts, decisions_made, blockers, status) (pipeline_id, task_id, from_department, to_department, artifacts, decisions_made, blockers, context_packet, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(pipeline_id, task_id, from_department, to_department, (pipeline_id, task_id, from_department, to_department,
_json_encode(artifacts), _json_encode(decisions_made), _json_encode(blockers), status), _json_encode(artifacts), _json_encode(decisions_made), _json_encode(blockers),
_json_encode(context_packet), status),
) )
conn.commit() conn.commit()
row = conn.execute( row = conn.execute(

View file

@ -0,0 +1,469 @@
"""Regression tests for KIN-DOCS-004 — context_packet как артефакт handoff.
Acceptance criteria:
1. Структурный тест agents/prompts/department_head.md: context_packet присутствует
в ## Return Format с 5 обязательными полями, упомянут в ## Focus On и ## Quality Checks.
2. Структурный тест agents/specialists.yaml: все 7 dept head ролей имеют
output_schema.context_packet.
3. Структурные тесты промптов-получателей (architect.md, backend_dev.md, frontend_dev.md):
явная инструкция читать context_packet первым (шаг 0 в ## Working Mode).
4. Unit-тест models.create_handoff: context_packet сохраняется как JSON и декодируется обратно.
5. Runner-тесты _execute_department_head_step: context_packet включён в summary output,
размещён первым ключом в initial context для воркеров; fail-open при отсутствии.
"""
import json
from pathlib import Path
from unittest.mock import patch, MagicMock, call
import pytest
import yaml
from core.db import init_db
from core import models
PROMPTS_DIR = Path(__file__).parent.parent / "agents" / "prompts"
SPECIALISTS_YAML = Path(__file__).parent.parent / "agents" / "specialists.yaml"
DEPT_HEAD_ROLES = [
"backend_head",
"frontend_head",
"qa_head",
"security_head",
"infra_head",
"research_head",
"marketing_head",
]
CONTEXT_PACKET_FIELDS = [
"architecture_notes",
"key_files",
"constraints",
"unknowns",
"handoff_for",
]
def _load_yaml():
return yaml.safe_load(SPECIALISTS_YAML.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def conn():
c = init_db(":memory:")
models.create_project(c, "proj", "TestProject", "~/projects/test",
tech_stack=["python"])
models.create_task(c, "PROJ-001", "proj", "Test task",
brief={"route_type": "dept_feature"})
yield c
c.close()
def _success_sub_result(pipeline_id=99):
return {
"success": True,
"total_cost_usd": 0.01,
"total_tokens": 100,
"total_duration_seconds": 2.0,
"steps_completed": 1,
"results": [{"role": "backend_dev", "output": "done"}],
"pipeline_id": pipeline_id,
}
def _dept_head_raw_output(with_context_packet=True, extra_fields=None):
"""Сборка raw_output для dept head, опционально с context_packet."""
out = {
"status": "done",
"sub_pipeline": [
{"role": "backend_dev", "model": "sonnet", "brief": "Implement feature"},
],
"artifacts": {"files_changed": ["core/models.py"], "notes": "done"},
"handoff_notes": "Backend complete",
}
if with_context_packet:
out["context_packet"] = {
"architecture_notes": "Pure functions, raw sqlite3",
"key_files": ["core/models.py", "core/db.py"],
"constraints": ["All DB columns need DEFAULT values"],
"unknowns": ["Frontend integration pending"],
"handoff_for": "backend_dev",
}
if extra_fields:
out.update(extra_fields)
return {"raw_output": json.dumps(out)}
# ===========================================================================
# 1. Структурные тесты agents/prompts/department_head.md
# ===========================================================================
class TestDepartmentHeadPromptStructure:
"""department_head.md содержит context_packet во всех нужных секциях."""
def _content(self):
return (PROMPTS_DIR / "department_head.md").read_text(encoding="utf-8")
def test_context_packet_in_return_format(self):
"""department_head.md содержит 'context_packet' в секции ## Return Format."""
content = self._content()
# Найти секцию Return Format и убедиться, что context_packet там есть
return_format_idx = content.find("## Return Format")
assert return_format_idx != -1, "department_head.md не содержит секцию '## Return Format'"
return_format_section = content[return_format_idx:]
assert "context_packet" in return_format_section, (
"department_head.md не содержит 'context_packet' в ## Return Format"
)
@pytest.mark.parametrize("field", CONTEXT_PACKET_FIELDS)
def test_context_packet_required_field_present(self, field):
"""department_head.md упоминает каждое из 5 обязательных полей context_packet."""
content = self._content()
assert field in content, (
f"department_head.md не содержит обязательного поля context_packet: {field!r}"
)
def test_context_packet_in_focus_on(self):
"""department_head.md упоминает context_packet в секции ## Focus On."""
content = self._content()
focus_on_idx = content.find("## Focus On")
assert focus_on_idx != -1, "department_head.md не содержит секцию '## Focus On'"
focus_on_section = content[focus_on_idx:]
quality_checks_idx = focus_on_section.find("## Quality Checks")
focus_on_body = focus_on_section[:quality_checks_idx] if quality_checks_idx != -1 else focus_on_section
assert "context_packet" in focus_on_body, (
"department_head.md не упоминает 'context_packet' в ## Focus On"
)
def test_context_packet_in_quality_checks(self):
"""department_head.md валидирует context_packet в ## Quality Checks."""
content = self._content()
qc_idx = content.find("## Quality Checks")
assert qc_idx != -1, "department_head.md не содержит секцию '## Quality Checks'"
qc_section = content[qc_idx:]
assert "context_packet" in qc_section, (
"department_head.md не упоминает 'context_packet' в ## Quality Checks"
)
# ===========================================================================
# 2. Структурный тест agents/specialists.yaml — 7 dept head ролей
# ===========================================================================
class TestSpecialistsYamlContextPacket:
"""Все 7 dept head ролей имеют output_schema.context_packet в specialists.yaml."""
@pytest.mark.parametrize("role", DEPT_HEAD_ROLES)
def test_dept_head_has_output_schema_context_packet(self, role):
"""specialists.yaml: роль {role} имеет output_schema.context_packet."""
data = _load_yaml()
specialists = data.get("specialists", {})
assert role in specialists, (
f"Роль {role!r} не найдена в specialists.yaml"
)
output_schema = specialists[role].get("output_schema", {})
assert "context_packet" in output_schema, (
f"specialists.yaml: {role} не имеет output_schema.context_packet"
)
def test_exactly_7_dept_heads_have_context_packet(self):
"""Ровно 7 dept head ролей имеют output_schema.context_packet."""
data = _load_yaml()
specialists = data.get("specialists", {})
roles_with_cp = [
role for role, spec in specialists.items()
if "context_packet" in spec.get("output_schema", {})
]
assert len(roles_with_cp) == 7, (
f"Ожидалось 7 ролей с output_schema.context_packet, найдено {len(roles_with_cp)}: {roles_with_cp}"
)
# ===========================================================================
# 3. Структурные тесты промптов-получателей context_packet
# ===========================================================================
class TestWorkerPromptsContextPacketInstruction:
"""architect.md, backend_dev.md, frontend_dev.md содержат инструкцию читать context_packet первым."""
@pytest.mark.parametrize("prompt_file", ["architect.md", "backend_dev.md", "frontend_dev.md"])
def test_worker_prompt_has_context_packet_read_first_instruction(self, prompt_file):
"""Промпт {prompt_file} содержит явную инструкцию читать context_packet первым."""
content = (PROMPTS_DIR / prompt_file).read_text(encoding="utf-8")
assert "context_packet" in content, (
f"{prompt_file} не содержит инструкцию про context_packet"
)
@pytest.mark.parametrize("prompt_file", ["architect.md", "backend_dev.md", "frontend_dev.md"])
def test_worker_prompt_context_packet_instruction_in_working_mode(self, prompt_file):
"""Инструкция про context_packet находится в секции ## Working Mode промпта."""
content = (PROMPTS_DIR / prompt_file).read_text(encoding="utf-8")
wm_idx = content.find("## Working Mode")
assert wm_idx != -1, f"{prompt_file} не содержит секцию '## Working Mode'"
wm_section = content[wm_idx:]
# Ограничиваем до следующей секции
next_section_idx = wm_section.find("\n## ", 1)
wm_body = wm_section[:next_section_idx] if next_section_idx != -1 else wm_section
assert "context_packet" in wm_body, (
f"{prompt_file}: инструкция читать context_packet отсутствует в ## Working Mode"
)
@pytest.mark.parametrize("prompt_file", ["architect.md", "backend_dev.md", "frontend_dev.md"])
def test_worker_prompt_context_packet_instruction_is_step_0(self, prompt_file):
"""Инструкция про context_packet пронумерована как шаг 0 в ## Working Mode."""
content = (PROMPTS_DIR / prompt_file).read_text(encoding="utf-8")
wm_idx = content.find("## Working Mode")
wm_section = content[wm_idx:]
next_section_idx = wm_section.find("\n## ", 1)
wm_body = wm_section[:next_section_idx] if next_section_idx != -1 else wm_section
# Шаг 0 должен содержать context_packet
assert "0." in wm_body and "context_packet" in wm_body, (
f"{prompt_file}: шаг 0 с инструкцией context_packet не найден в ## Working Mode"
)
# ===========================================================================
# 4. Unit-тесты models.create_handoff с context_packet
# ===========================================================================
class TestCreateHandoffContextPacket:
"""Unit-тесты models.create_handoff — сохранение и чтение context_packet."""
def test_create_handoff_stores_context_packet_dict(self, conn):
"""create_handoff с context_packet dict: возвращает его как dict."""
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
cp = {
"architecture_notes": "Pure functions",
"key_files": ["core/models.py"],
"constraints": ["No ORM"],
"unknowns": [],
"handoff_for": "backend_dev",
}
handoff = models.create_handoff(
conn, pipeline["id"], "PROJ-001",
from_department="backend",
context_packet=cp,
)
assert handoff["context_packet"] == cp, (
"create_handoff не вернул context_packet как dict"
)
def test_create_handoff_context_packet_none_returns_none(self, conn):
"""create_handoff без context_packet: поле возвращается как None."""
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
handoff = models.create_handoff(
conn, pipeline["id"], "PROJ-001",
from_department="backend",
context_packet=None,
)
assert handoff["context_packet"] is None, (
"create_handoff без context_packet должен возвращать None, не что-то другое"
)
def test_create_handoff_context_packet_persisted_in_db(self, conn):
"""create_handoff с context_packet: поле сохраняется в БД и декодируется при SELECT."""
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
cp = {"architecture_notes": "test", "key_files": ["a.py"], "constraints": [],
"unknowns": ["q1"], "handoff_for": "backend_dev"}
handoff = models.create_handoff(
conn, pipeline["id"], "PROJ-001",
from_department="backend",
context_packet=cp,
)
# Перечитать из БД через get_handoffs_for_task
handoffs = models.get_handoffs_for_task(conn, "PROJ-001")
assert len(handoffs) == 1
assert handoffs[0]["context_packet"] == cp, (
"context_packet не совпадает после повторного чтения из БД"
)
def test_context_packet_in_json_columns(self):
"""'context_packet' присутствует в models._JSON_COLUMNS."""
assert "context_packet" in models._JSON_COLUMNS, (
"'context_packet' должен быть в models._JSON_COLUMNS для автодекодирования"
)
# ===========================================================================
# 5. Runner-тесты _execute_department_head_step
# ===========================================================================
class TestExecuteDeptHeadStepContextPacket:
"""Тесты _execute_department_head_step: context_packet в output и initial context."""
def test_context_packet_present_in_summary_output(self, conn):
"""Если dept head вернул context_packet — он включён в summary output для следующего шага."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
with patch("agents.runner.run_pipeline", return_value=_success_sub_result(pipeline["id"])), \
patch("agents.runner.models.create_handoff"):
result = _execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=True),
next_department="frontend",
)
assert result["success"] is True
summary = json.loads(result["output"])
assert "context_packet" in summary, (
"context_packet должен быть в summary output для следующего dept head"
)
cp = summary["context_packet"]
assert cp["handoff_for"] == "backend_dev"
assert "core/models.py" in cp["key_files"]
def test_context_packet_placed_first_in_worker_initial_context(self, conn):
"""Если context_packet есть — он первый ключ в initial_previous_output для воркеров."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
captured_initial_output = {}
def capture_run_pipeline(conn, task_id, steps, *args, **kwargs):
initial = kwargs.get("initial_previous_output", "{}")
captured_initial_output["value"] = json.loads(initial)
return _success_sub_result(pipeline["id"])
with patch("agents.runner.run_pipeline", side_effect=capture_run_pipeline), \
patch("agents.runner.models.create_handoff"):
_execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=True),
)
ctx = captured_initial_output.get("value", {})
assert "context_packet" in ctx, (
"context_packet отсутствует в initial_previous_output для воркеров"
)
keys = list(ctx.keys())
assert keys[0] == "context_packet", (
f"context_packet должен быть первым ключом в initial context, но первый: {keys[0]!r}"
)
def test_missing_context_packet_does_not_block_pipeline(self, conn):
"""Если context_packet отсутствует — pipeline продолжается (fail-open)."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
with patch("agents.runner.run_pipeline", return_value=_success_sub_result(pipeline["id"])), \
patch("agents.runner.models.create_handoff"):
result = _execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=False),
next_department="frontend",
)
assert result["success"] is True, (
"Pipeline должен продолжаться при отсутствии context_packet (fail-open)"
)
def test_missing_context_packet_not_in_worker_initial_context(self, conn):
"""Если context_packet отсутствует — его ключа нет в initial context для воркеров."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
captured = {}
def capture_run_pipeline(conn, task_id, steps, *args, **kwargs):
initial = kwargs.get("initial_previous_output", "{}")
captured["value"] = json.loads(initial)
return _success_sub_result(pipeline["id"])
with patch("agents.runner.run_pipeline", side_effect=capture_run_pipeline), \
patch("agents.runner.models.create_handoff"):
_execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=False),
)
ctx = captured.get("value", {})
assert "context_packet" not in ctx, (
"context_packet не должен быть в initial context, если dept head его не вернул"
)
# Но department_head_plan должен всё равно присутствовать
assert "department_head_plan" in ctx, (
"department_head_plan должен присутствовать в initial context даже без context_packet"
)
def test_context_packet_passed_to_create_handoff(self, conn):
"""context_packet передаётся в models.create_handoff при сохранении хэндоффа."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
with patch("agents.runner.run_pipeline", return_value=_success_sub_result(pipeline["id"])), \
patch("agents.runner.models.create_handoff") as mock_handoff:
_execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=True),
)
assert mock_handoff.called, "models.create_handoff должен быть вызван"
_, kwargs = mock_handoff.call_args
cp = kwargs.get("context_packet")
assert cp is not None, "context_packet должен быть передан в create_handoff"
assert cp["handoff_for"] == "backend_dev"
def test_missing_context_packet_handoff_receives_none(self, conn):
"""Если context_packet отсутствует — create_handoff вызывается с context_packet=None."""
from agents.runner import _execute_department_head_step
pipeline = models.create_pipeline(
conn, "PROJ-001", "proj", "dept_feature", [{"role": "backend_head"}]
)
with patch("agents.runner.run_pipeline", return_value=_success_sub_result(pipeline["id"])), \
patch("agents.runner.models.create_handoff") as mock_handoff:
_execute_department_head_step(
conn=conn,
task_id="PROJ-001",
project_id="proj",
parent_pipeline_id=pipeline["id"],
step={"role": "backend_head", "brief": "plan"},
dept_head_result=_dept_head_raw_output(with_context_packet=False),
)
assert mock_handoff.called
_, kwargs = mock_handoff.call_args
assert kwargs.get("context_packet") is None, (
"create_handoff должен получить context_packet=None, если dept head не вернул его"
)