Compare commits
3 commits
de52526659
...
5750b72e8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5750b72e8b | ||
|
|
39b6f38414 | ||
|
|
ae2f0f1c81 |
9 changed files with 535 additions and 12 deletions
|
|
@ -15,6 +15,7 @@ You receive:
|
|||
|
||||
**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
|
||||
2. Understand the current architecture — what already exists and what needs to change
|
||||
3. Design the solution: data model, interfaces, component interactions
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ You receive:
|
|||
|
||||
## 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ You receive:
|
|||
- 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
|
||||
- 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:**
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ You receive:
|
|||
- Each worker brief is self-contained — no "see above" references
|
||||
- Artifacts list is complete and specific
|
||||
- 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
|
||||
|
||||
|
|
@ -72,7 +74,14 @@ Return ONLY valid JSON (no markdown, no explanation):
|
|||
"schemas": [],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ You receive:
|
|||
|
||||
## 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1347,14 +1347,29 @@ def _execute_department_head_step(
|
|||
role = step["role"]
|
||||
dept_name = role.replace("_head", "")
|
||||
|
||||
# Build initial context for workers: dept head's plan + artifacts
|
||||
dept_plan_context = json.dumps({
|
||||
"department_head_plan": {
|
||||
"department": dept_name,
|
||||
"artifacts": parsed.get("artifacts", {}),
|
||||
"handoff_notes": parsed.get("handoff_notes", ""),
|
||||
},
|
||||
}, ensure_ascii=False)
|
||||
# Extract context_packet (KIN-DOCS-004): fail-open if missing
|
||||
context_packet = parsed.get("context_packet")
|
||||
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,
|
||||
"artifacts": parsed.get("artifacts", {}),
|
||||
"handoff_notes": parsed.get("handoff_notes", ""),
|
||||
}
|
||||
dept_plan_context = json.dumps(dept_plan_context_dict, ensure_ascii=False)
|
||||
|
||||
# KIN-084: log sub-pipeline start
|
||||
if parent_pipeline_id:
|
||||
|
|
@ -1429,6 +1444,7 @@ def _execute_department_head_step(
|
|||
decisions_made=decisions_made,
|
||||
blockers=[],
|
||||
status=handoff_status,
|
||||
context_packet=context_packet,
|
||||
)
|
||||
except Exception:
|
||||
pass # Handoff save errors must never block pipeline
|
||||
|
|
@ -1438,6 +1454,7 @@ def _execute_department_head_step(
|
|||
"from_department": dept_name,
|
||||
"handoff_notes": parsed.get("handoff_notes", ""),
|
||||
"artifacts": parsed.get("artifacts", {}),
|
||||
"context_packet": context_packet,
|
||||
"sub_pipeline_summary": {
|
||||
"steps_completed": sub_result.get("steps_completed", 0),
|
||||
"success": sub_result.get("success", False),
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ specialists:
|
|||
context_rules:
|
||||
decisions: all
|
||||
modules: all
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
frontend_head:
|
||||
name: "Frontend Department Head"
|
||||
|
|
@ -193,6 +195,8 @@ specialists:
|
|||
context_rules:
|
||||
decisions: all
|
||||
modules: all
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
qa_head:
|
||||
name: "QA Department Head"
|
||||
|
|
@ -204,6 +208,8 @@ specialists:
|
|||
permissions: read_only
|
||||
context_rules:
|
||||
decisions: all
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
security_head:
|
||||
name: "Security Department Head"
|
||||
|
|
@ -215,6 +221,8 @@ specialists:
|
|||
permissions: read_only
|
||||
context_rules:
|
||||
decisions_category: security
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
infra_head:
|
||||
name: "Infrastructure Department Head"
|
||||
|
|
@ -226,6 +234,8 @@ specialists:
|
|||
permissions: read_only
|
||||
context_rules:
|
||||
decisions: all
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
knowledge_synthesizer:
|
||||
name: "Knowledge Synthesizer"
|
||||
|
|
@ -252,6 +262,8 @@ specialists:
|
|||
permissions: read_only
|
||||
context_rules:
|
||||
decisions: all
|
||||
output_schema:
|
||||
context_packet: "{ architecture_notes: string, key_files: array, constraints: array, unknowns: array, handoff_for: string }"
|
||||
|
||||
marketing_head:
|
||||
name: "Marketing Department Head"
|
||||
|
|
@ -264,6 +276,8 @@ specialists:
|
|||
context_rules:
|
||||
decisions: 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:
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ CREATE TABLE IF NOT EXISTS department_handoffs (
|
|||
artifacts JSON,
|
||||
decisions_made JSON,
|
||||
blockers JSON,
|
||||
context_packet JSON DEFAULT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
@ -703,6 +704,13 @@ def _migrate(conn: sqlite3.Connection):
|
|||
""")
|
||||
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)
|
||||
projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
|
||||
if "test_command" not in projects_cols:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ _JSON_COLUMNS: frozenset[str] = frozenset({
|
|||
"dependencies",
|
||||
"steps",
|
||||
"artifacts", "decisions_made", "blockers",
|
||||
"context_packet",
|
||||
"extra_json",
|
||||
"pending_actions",
|
||||
})
|
||||
|
|
@ -1191,14 +1192,16 @@ def create_handoff(
|
|||
decisions_made: list | None = None,
|
||||
blockers: list | None = None,
|
||||
status: str = "pending",
|
||||
context_packet: dict | list | None = None,
|
||||
) -> dict:
|
||||
"""Record a department handoff with artifacts for inter-department context."""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO department_handoffs
|
||||
(pipeline_id, task_id, from_department, to_department, artifacts, decisions_made, blockers, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(pipeline_id, task_id, from_department, to_department, artifacts, decisions_made, blockers, context_packet, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(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()
|
||||
row = conn.execute(
|
||||
|
|
|
|||
469
tests/test_kin_docs_004_regression.py
Normal file
469
tests/test_kin_docs_004_regression.py
Normal 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 не вернул его"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue