Files
echo-core/tests/test_planning_orchestrator.py
Marius Mutu 51e56af557 feat(ralph): conversational planning agent (W2)
Echo Core devine planning agent: poartă o conversație multi-fază cu Marius
folosind skill-urile gstack (/office-hours → /plan-ceo-review →
/plan-eng-review → /plan-design-review opt) și produce final-plan.md în
~/workspace/<slug>/scripts/ralph/, gata să fie consumat de Ralph PRD
generator (W3) noaptea.

Decizii arhitecturale (din eng review + spike findings):
- PlanningSession ca clasă SEPARATĂ de chat-ul main (NU mode=string param)
  — separation explicit. claude_session.py rămâne strict pentru chat;
  planning trăiește în src/planning_session.py + src/planning_orchestrator.py.
  Inheritance literală nu se aplică (claude_session.py expune funcții
  module-level, nu o clasă) — separation e satisfacută prin module distinct.
- Fresh subprocess PER skill phase, NU single resumed session — phase-urile
  coordinează via disk artifacts (gstack convention în
  ~/.gstack/projects/<slug>/). Avoids context window growth.
- --max-turns 20 default + retry pe error_max_turns la --max-turns 30.
  Spike a arătat că prompt-uri complexe pot exploda turn budget-ul.
- approved-tasks.json schema extins cu planning_session_id + final_plan_path
  (Status flow: pending → planning → approved → running → complete).
- State separat în sessions/planning.json (NU active.json), keyed pe
  (adapter, channel_id) pentru re-resume la restart echo-core.

Trigger-e:
- Discord: slash command /plan <slug> [descriere] cu autocomplete pe pending,
  buton "🧠 Planifică" în RalphProjectView, și /cancel slash command.
- Telegram: /plan + /cancel commands, plus buton "🧠 Planifică" în
  ralph project keyboard.
- Router: state-aware routing — dacă chat-ul e în planning, mesajele plain
  trec la PlanningOrchestrator.respond() prin --resume; /cancel revine la
  status pending; /advance / "Continuă faza" advance fază nouă (fresh
  subprocess); /finalize sau "Dau drumul" promote la status approved.

Discord defer pattern: toate butoanele noi (PlanningActiveView,
PlanningFinalView, "🧠 Planifică") apelează await
interaction.response.defer(ephemeral=True) ÎNAINTE de orice IO — evită
"Interaction failed" pe IO >3s.

UX strings warm + colaborativ (per design review): "🧠 Pornesc planning
pentru ...", "Răspunde aici", "Continuă faza", "Dau drumul tonight",
"Anulează" — niciun "Submit/Approve/Cancel" generic.

Tests: 23 noi (test_planning_session, test_planning_orchestrator,
test_router_planning) — toate pass. Mock pe _run_claude pentru a evita
subprocess Claude real în CI.

Files new:
  prompts/planning_agent.md
  src/planning_session.py
  src/planning_orchestrator.py
  tests/test_planning_session.py
  tests/test_planning_orchestrator.py
  tests/test_router_planning.py

Files modified:
  src/claude_session.py        — _run_claude(cwd=...) optional + surface subtype/is_error
  src/router.py                — state-aware routing, start_planning_session, planning_advance/approve/cancel, _ralph_propose schema cu planning_session_id + final_plan_path
  src/adapters/discord_bot.py  — /plan + /cancel slash commands; planning views imported
  src/adapters/discord_views.py — PlanningActiveView, PlanningFinalView, "Planifică" button în RalphProjectView, _split_chunks helper
  src/adapters/telegram_bot.py — /plan + /cancel handlers, callback_ralph extins cu plan/planadvance/plancancel/planapprove, planning keyboards

Status testelor pe modulele atinse: 75 passed, 0 failed
(test_claude_session security_section preexistent — neatins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:38:51 +00:00

244 lines
9.0 KiB
Python

"""Tests for src/planning_orchestrator.py — phase pipeline coordinator."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from src import planning_orchestrator, planning_session
from src.planning_orchestrator import (
BASE_PHASES,
DESIGN_PHASE,
PlanningOrchestrator,
_phases_for,
has_ui_scope,
)
@pytest.fixture
def tmp_planning_state(tmp_path, monkeypatch):
fake_sessions_dir = tmp_path / "sessions"
fake_sessions_dir.mkdir()
fake_state = fake_sessions_dir / "planning.json"
monkeypatch.setattr(planning_session, "SESSIONS_DIR", fake_sessions_dir)
monkeypatch.setattr(planning_session, "PLANNING_STATE_FILE", fake_state)
yield fake_state
@pytest.fixture
def fake_workspace(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "demo").mkdir()
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
monkeypatch.setattr(planning_orchestrator, "WORKSPACE_ROOT", workspace)
yield workspace
# ---------------------------------------------------------------------------
# has_ui_scope / _phases_for
# ---------------------------------------------------------------------------
class TestUiScopeHeuristic:
@pytest.mark.parametrize("text,expected", [
("redesign UI for the dashboard", True),
("add a button on settings page", True),
("frontend cleanup", True),
("Adaugă filtru genuri pe pagina de game-library", True), # ro
("schimbă culoarea butonului de submit", True), # ro
("refactor utility helpers", False),
("rewrite the database migration scripts", False),
("tweak the rate limiter", False),
])
def test_detects_ui_keywords(self, text, expected):
assert has_ui_scope(text) is expected
def test_phases_for_excludes_design_when_no_ui(self):
phases = _phases_for("refactor utility")
assert phases == BASE_PHASES
assert DESIGN_PHASE not in phases
def test_phases_for_appends_design_for_ui(self):
phases = _phases_for("add login page")
assert phases[-1] == DESIGN_PHASE
assert phases[: len(BASE_PHASES)] == BASE_PHASES
# ---------------------------------------------------------------------------
# Orchestrator start / respond / advance / cancel — mock subprocess
# ---------------------------------------------------------------------------
def _fake_result(session_id="sess-1", text="hi"):
return {
"result": text,
"session_id": session_id,
"usage": {"input_tokens": 1000, "output_tokens": 200},
"total_cost_usd": 0.4,
"subtype": "success",
"is_error": False,
}
class TestOrchestratorStart:
def test_start_persists_phases_planned(
self, tmp_planning_state, fake_workspace
):
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1"),
):
sess, first = PlanningOrchestrator.start(
"demo", "Add login button", "ch-1", adapter="discord"
)
assert sess.session_id == "s-1"
from src.planning_session import get_planning_state
state = get_planning_state("discord", "ch-1")
assert state["phase"] == BASE_PHASES[0] # /office-hours
# UI scope → design phase included
assert state["phases_planned"][-1] == DESIGN_PHASE
assert state["phase_index"] == 0
assert state["phases_completed"] == []
def test_start_no_ui_scope_no_design_phase(
self, tmp_planning_state, fake_workspace
):
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1"),
):
PlanningOrchestrator.start(
"demo", "refactor utility helpers", "ch-1", adapter="discord"
)
from src.planning_session import get_planning_state
state = get_planning_state("discord", "ch-1")
assert DESIGN_PHASE not in state["phases_planned"]
class TestOrchestratorRespond:
def test_respond_returns_text_and_phase_ready_marker(
self, tmp_planning_state, fake_workspace
):
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1", text="initial"),
):
PlanningOrchestrator.start("demo", "Add login button", "ch-1", "discord")
ready_text = "ok we are done. PHASE_STATUS: ready_to_advance"
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1", text=ready_text),
):
sess, response, ready = PlanningOrchestrator.respond(
"discord", "ch-1", "user reply"
)
assert response == ready_text
assert ready is True
assert sess is not None
def test_respond_returns_none_when_no_state(self, tmp_planning_state):
sess, text, ready = PlanningOrchestrator.respond(
"discord", "ch-missing", "hi"
)
assert sess is None
assert "Nu există" in text
assert ready is False
class TestOrchestratorAdvance:
def test_advance_starts_next_phase_fresh_subprocess(
self, tmp_planning_state, fake_workspace
):
# Phase 1 → office-hours
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1"),
):
PlanningOrchestrator.start(
"demo", "Add login button", "ch-1", "discord"
)
# Advance → /plan-ceo-review fresh subprocess
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-2", text="ceo phase started"),
) as mock_run:
sess, text, completed = PlanningOrchestrator.advance(
"discord", "ch-1"
)
mock_run.assert_called_once()
# Verify the new subprocess has /plan-ceo-review in prompt (NOT --resume)
cmd = mock_run.call_args[0][0]
assert "/plan-ceo-review" in cmd[2]
assert "--resume" not in cmd
assert sess.session_id == "s-2"
assert sess.phase == "/plan-ceo-review"
assert completed is False
from src.planning_session import get_planning_state
state = get_planning_state("discord", "ch-1")
assert "/office-hours" in state["phases_completed"]
assert state["phase_index"] == 1
def test_advance_writes_final_plan_when_pipeline_complete(
self, tmp_planning_state, fake_workspace
):
# Manually seed state at the last phase.
from src.planning_session import _save_planning_state, _channel_key
# Build phase plan with 2 phases for brevity (skip design for non-UI).
state = {
_channel_key("discord", "ch-1"): {
"slug": "demo",
"description": "refactor utility",
"phase": "/plan-eng-review",
"phases_planned": ["/office-hours", "/plan-ceo-review", "/plan-eng-review"],
"phase_index": 2,
"phases_completed": ["/office-hours", "/plan-ceo-review"],
"session_id": "s-eng",
"planning_session_id": "ps-uuid",
"adapter": "discord",
"channel_id": "ch-1",
"started_at": "2026-04-26T20:00:00+00:00",
"updated_at": "2026-04-26T20:30:00+00:00",
"last_text_excerpt": "eng review done",
"last_subtype": "success",
}
}
_save_planning_state(state)
# Advance with no more phases — should write final-plan stub, no claude call.
with patch("src.planning_session._run_claude") as mock_run:
sess, text, completed = PlanningOrchestrator.advance(
"discord", "ch-1"
)
mock_run.assert_not_called()
assert completed is True
plan_path = PlanningOrchestrator.final_plan_path("demo")
assert plan_path.exists()
content = plan_path.read_text(encoding="utf-8")
assert "demo" in content
assert "refactor utility" in content
# All phases listed
assert "/office-hours" in content
assert "/plan-ceo-review" in content
assert "/plan-eng-review" in content
class TestOrchestratorCancel:
def test_cancel_clears_state(self, tmp_planning_state, fake_workspace):
with patch(
"src.planning_session._run_claude",
return_value=_fake_result(session_id="s-1"),
):
PlanningOrchestrator.start("demo", "x", "ch-1", "discord")
from src.planning_session import is_in_planning
assert is_in_planning("discord", "ch-1") is True
assert PlanningOrchestrator.cancel("discord", "ch-1") is True
assert is_in_planning("discord", "ch-1") is False
def test_cancel_returns_false_when_no_state(self, tmp_planning_state):
assert PlanningOrchestrator.cancel("discord", "ch-x") is False