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>
This commit is contained in:
243
tests/test_planning_orchestrator.py
Normal file
243
tests/test_planning_orchestrator.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""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
|
||||
278
tests/test_planning_session.py
Normal file
278
tests/test_planning_session.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Tests for src/planning_session.py — PlanningSession + state persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import planning_session
|
||||
from src.planning_session import (
|
||||
PHASE_NEEDS_INPUT_MARKER,
|
||||
PHASE_READY_MARKER,
|
||||
PlanningSession,
|
||||
_channel_key,
|
||||
build_planning_system_prompt,
|
||||
clear_planning_state,
|
||||
get_planning_state,
|
||||
is_in_planning,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_planning_state(tmp_path, monkeypatch):
|
||||
"""Redirect planning state file into a tmp dir for each test."""
|
||||
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):
|
||||
"""Pretend ~/workspace/<slug>/ exists so PlanningSession.cwd resolves."""
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "demo").mkdir()
|
||||
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||
yield workspace
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_planning_system_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildPlanningSystemPrompt:
|
||||
def test_substitutes_slug_phase_description(self):
|
||||
prompt = build_planning_system_prompt(
|
||||
slug="demo", description="Add filter X", phase="/office-hours"
|
||||
)
|
||||
# Even if the prompt template differs, the values must appear at least
|
||||
# once each.
|
||||
assert "demo" in prompt
|
||||
assert "Add filter X" in prompt
|
||||
assert "/office-hours" in prompt
|
||||
|
||||
def test_returns_empty_when_template_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
planning_session, "PLANNING_PROMPT_FILE", tmp_path / "missing.md"
|
||||
)
|
||||
assert build_planning_system_prompt("a", "b", "/x") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# state get/set/clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanningState:
|
||||
def test_clear_returns_false_when_absent(self, tmp_planning_state):
|
||||
assert clear_planning_state("discord", "ch-1") is False
|
||||
|
||||
def test_get_returns_none_when_absent(self, tmp_planning_state):
|
||||
assert get_planning_state("discord", "ch-1") is None
|
||||
assert is_in_planning("discord", "ch-1") is False
|
||||
|
||||
def test_persist_and_recover(self, tmp_planning_state, fake_workspace):
|
||||
# Build a session WITHOUT actually invoking claude — call _persist directly.
|
||||
sess = PlanningSession(
|
||||
slug="demo",
|
||||
description="desc",
|
||||
phase="/office-hours",
|
||||
channel_id="ch-1",
|
||||
adapter="discord",
|
||||
session_id="sess-uuid-1",
|
||||
)
|
||||
sess._last_response = "hello world " + PHASE_NEEDS_INPUT_MARKER
|
||||
sess._last_subtype = "success"
|
||||
sess._persist(action="start", cost_usd=0.42)
|
||||
|
||||
assert is_in_planning("discord", "ch-1") is True
|
||||
state = get_planning_state("discord", "ch-1")
|
||||
assert state is not None
|
||||
assert state["slug"] == "demo"
|
||||
assert state["session_id"] == "sess-uuid-1"
|
||||
assert state["phase"] == "/office-hours"
|
||||
assert state["last_subtype"] == "success"
|
||||
assert "hello world" in state["last_text_excerpt"]
|
||||
|
||||
recovered = PlanningSession.from_state("discord", "ch-1")
|
||||
assert recovered is not None
|
||||
assert recovered.slug == "demo"
|
||||
assert recovered.session_id == "sess-uuid-1"
|
||||
assert recovered.phase == "/office-hours"
|
||||
|
||||
assert clear_planning_state("discord", "ch-1") is True
|
||||
assert get_planning_state("discord", "ch-1") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_phase_ready
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsPhaseReady:
|
||||
def test_returns_true_when_marker_present(self, fake_workspace):
|
||||
sess = PlanningSession("demo", "d", "/x", "ch", session_id="abc")
|
||||
sess._last_response = f"some text {PHASE_READY_MARKER}"
|
||||
assert sess.is_phase_ready() is True
|
||||
|
||||
def test_returns_false_when_marker_absent(self, fake_workspace):
|
||||
sess = PlanningSession("demo", "d", "/x", "ch", session_id="abc")
|
||||
sess._last_response = "some text without marker"
|
||||
assert sess.is_phase_ready() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cwd resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCwd:
|
||||
def test_workspace_dir_used_when_present(self, fake_workspace):
|
||||
sess = PlanningSession("demo", "d", "/x", "ch")
|
||||
assert sess.cwd == fake_workspace / "demo"
|
||||
|
||||
def test_falls_back_to_project_root_when_missing(
|
||||
self, fake_workspace, monkeypatch
|
||||
):
|
||||
sess = PlanningSession("nonexistent-slug", "d", "/x", "ch")
|
||||
# Falls back to PROJECT_ROOT
|
||||
assert sess.cwd == planning_session.PROJECT_ROOT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# command construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildCmd:
|
||||
def test_start_includes_skill_phase_and_max_turns(self, fake_workspace):
|
||||
sess = PlanningSession("demo", "Add filter", "/office-hours", "ch")
|
||||
cmd = sess._build_cmd(
|
||||
"/office-hours Add filter",
|
||||
resume=None,
|
||||
max_turns=20,
|
||||
with_system_prompt=False,
|
||||
)
|
||||
assert cmd[0:3] == [planning_session.CLAUDE_BIN, "-p", "/office-hours Add filter"]
|
||||
assert "--max-turns" in cmd
|
||||
assert "20" in cmd
|
||||
assert "--output-format" in cmd
|
||||
assert "stream-json" in cmd
|
||||
assert "--dangerously-skip-permissions" in cmd
|
||||
# No --resume on a fresh start
|
||||
assert "--resume" not in cmd
|
||||
|
||||
def test_resume_includes_resume_flag(self, fake_workspace):
|
||||
sess = PlanningSession(
|
||||
"demo", "Add filter", "/office-hours", "ch", session_id="abc"
|
||||
)
|
||||
cmd = sess._build_cmd(
|
||||
"user reply",
|
||||
resume="abc",
|
||||
max_turns=20,
|
||||
with_system_prompt=False,
|
||||
)
|
||||
assert "--resume" in cmd
|
||||
assert "abc" in cmd
|
||||
|
||||
def test_with_system_prompt_appends_flag(self, fake_workspace, tmp_path, monkeypatch):
|
||||
# Create a tiny prompt file so build_planning_system_prompt returns text.
|
||||
fake = tmp_path / "planning_agent.md"
|
||||
fake.write_text("phase={phase} slug={slug}", encoding="utf-8")
|
||||
monkeypatch.setattr(planning_session, "PLANNING_PROMPT_FILE", fake)
|
||||
sess = PlanningSession("demo", "d", "/office-hours", "ch")
|
||||
cmd = sess._build_cmd(
|
||||
"prompt", resume=None, max_turns=20, with_system_prompt=True
|
||||
)
|
||||
assert "--system-prompt" in cmd
|
||||
idx = cmd.index("--system-prompt")
|
||||
assert "phase=/office-hours" in cmd[idx + 1]
|
||||
assert "slug=demo" in cmd[idx + 1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start() — integration-flavoured, mocks _run_claude
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStart:
|
||||
def test_persists_session_id_and_response(
|
||||
self, tmp_planning_state, fake_workspace
|
||||
):
|
||||
fake_result = {
|
||||
"result": "Bună! Câteva întrebări… " + PHASE_NEEDS_INPUT_MARKER,
|
||||
"session_id": "claude-uuid-99",
|
||||
"usage": {"input_tokens": 1000, "output_tokens": 200},
|
||||
"total_cost_usd": 0.55,
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
}
|
||||
with patch("src.planning_session._run_claude", return_value=fake_result) as mock_run:
|
||||
sess = PlanningSession.start(
|
||||
slug="demo",
|
||||
description="Add filter X",
|
||||
phase="/office-hours",
|
||||
channel_id="ch-1",
|
||||
adapter="discord",
|
||||
)
|
||||
mock_run.assert_called_once()
|
||||
# cwd kw passed
|
||||
_, kwargs = mock_run.call_args
|
||||
assert "cwd" in kwargs
|
||||
assert sess.session_id == "claude-uuid-99"
|
||||
assert "Bună" in sess.last_response
|
||||
state = get_planning_state("discord", "ch-1")
|
||||
assert state["session_id"] == "claude-uuid-99"
|
||||
assert state["slug"] == "demo"
|
||||
assert state["phase"] == "/office-hours"
|
||||
|
||||
def test_retries_on_error_max_turns(self, tmp_planning_state, fake_workspace):
|
||||
# First call returns error_max_turns with no session_id; second returns success.
|
||||
first = {
|
||||
"result": "deep tool use",
|
||||
"session_id": "",
|
||||
"usage": {},
|
||||
"total_cost_usd": 0.6,
|
||||
"subtype": "error_max_turns",
|
||||
"is_error": True,
|
||||
}
|
||||
second = {
|
||||
"result": "now I have a question",
|
||||
"session_id": "claude-uuid-2",
|
||||
"usage": {},
|
||||
"total_cost_usd": 0.7,
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
}
|
||||
with patch(
|
||||
"src.planning_session._run_claude", side_effect=[first, second]
|
||||
) as mock_run:
|
||||
sess = PlanningSession.start(
|
||||
slug="demo",
|
||||
description="Add filter X",
|
||||
phase="/office-hours",
|
||||
channel_id="ch-1",
|
||||
adapter="discord",
|
||||
)
|
||||
assert mock_run.call_count == 2
|
||||
assert sess.session_id == "claude-uuid-2"
|
||||
# Second call uses RETRY_MAX_TURNS
|
||||
second_args = mock_run.call_args_list[1][0][0]
|
||||
assert "30" in second_args # RETRY_MAX_TURNS
|
||||
|
||||
def test_respond_requires_session_id(self, fake_workspace):
|
||||
sess = PlanningSession("demo", "d", "/x", "ch") # no session_id
|
||||
with pytest.raises(RuntimeError):
|
||||
sess.respond("hello")
|
||||
273
tests/test_router_planning.py
Normal file
273
tests/test_router_planning.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Tests for src/router.py planning integration (W2 — state-aware routing,
|
||||
start_planning_session, planning_approve, planning_cancel)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import planning_session, planning_orchestrator, router
|
||||
from src.planning_session import _channel_key, _save_planning_state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_state(tmp_path, monkeypatch):
|
||||
"""Redirect planning + approved-tasks into tmp."""
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
monkeypatch.setattr(planning_session, "SESSIONS_DIR", sessions_dir)
|
||||
monkeypatch.setattr(
|
||||
planning_session, "PLANNING_STATE_FILE", sessions_dir / "planning.json"
|
||||
)
|
||||
|
||||
# approved-tasks.json — point router at a tmp file
|
||||
approved = tmp_path / "approved-tasks.json"
|
||||
approved.write_text(json.dumps({"projects": [], "last_updated": None}))
|
||||
monkeypatch.setattr(router, "APPROVED_TASKS_FILE", approved)
|
||||
|
||||
# workspace dir for planning orchestrator final-plan.md target
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "demo").mkdir()
|
||||
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||
monkeypatch.setattr(planning_orchestrator, "WORKSPACE_ROOT", workspace)
|
||||
yield {"sessions": sessions_dir, "approved": approved, "workspace": workspace}
|
||||
|
||||
|
||||
def _fake_result(session_id="s-1", text="hi"):
|
||||
return {
|
||||
"result": text,
|
||||
"session_id": session_id,
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
"total_cost_usd": 0.1,
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_planning_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartPlanningSession:
|
||||
def test_creates_entry_and_sets_status_planning(self, tmp_state):
|
||||
with patch(
|
||||
"src.planning_session._run_claude",
|
||||
return_value=_fake_result(session_id="s-1", text="first"),
|
||||
):
|
||||
response = router.start_planning_session(
|
||||
"demo", "Add filter X", "ch-1", "discord"
|
||||
)
|
||||
assert response == "first"
|
||||
approved = json.loads(tmp_state["approved"].read_text())
|
||||
assert len(approved["projects"]) == 1
|
||||
entry = approved["projects"][0]
|
||||
assert entry["name"] == "demo"
|
||||
assert entry["status"] == "planning"
|
||||
assert entry["planning_session_id"] # uuid
|
||||
|
||||
def test_promotes_existing_pending_entry(self, tmp_state):
|
||||
# Pre-seed an existing pending entry
|
||||
approved_data = {
|
||||
"projects": [
|
||||
{
|
||||
"name": "demo",
|
||||
"description": "from earlier",
|
||||
"status": "pending",
|
||||
"planning_session_id": None,
|
||||
"final_plan_path": None,
|
||||
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||
"approved_at": None,
|
||||
"started_at": None,
|
||||
"pid": None,
|
||||
}
|
||||
],
|
||||
"last_updated": None,
|
||||
}
|
||||
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||
|
||||
with patch(
|
||||
"src.planning_session._run_claude",
|
||||
return_value=_fake_result(session_id="s-2", text="hi"),
|
||||
):
|
||||
router.start_planning_session(
|
||||
"demo", "Add filter X", "ch-1", "discord"
|
||||
)
|
||||
approved = json.loads(tmp_state["approved"].read_text())
|
||||
assert len(approved["projects"]) == 1
|
||||
assert approved["projects"][0]["status"] == "planning"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# planning_approve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanningApprove:
|
||||
def test_promotes_status_and_clears_state(self, tmp_state):
|
||||
# Seed an active planning state + approved-tasks pending entry
|
||||
_save_planning_state({
|
||||
_channel_key("discord", "ch-1"): {
|
||||
"slug": "demo",
|
||||
"description": "x",
|
||||
"phase": "__complete__",
|
||||
"phases_planned": ["/office-hours"],
|
||||
"phases_completed": ["/office-hours"],
|
||||
"phase_index": 1,
|
||||
"session_id": "s-uuid",
|
||||
"planning_session_id": "ps-uuid",
|
||||
"final_plan_path": "/tmp/final-plan.md",
|
||||
"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": "done",
|
||||
"last_subtype": "success",
|
||||
}
|
||||
})
|
||||
approved_data = {
|
||||
"projects": [
|
||||
{
|
||||
"name": "demo",
|
||||
"description": "x",
|
||||
"status": "planning",
|
||||
"planning_session_id": "ps-uuid",
|
||||
"final_plan_path": None,
|
||||
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||
"approved_at": None,
|
||||
"started_at": None,
|
||||
"pid": None,
|
||||
}
|
||||
],
|
||||
"last_updated": None,
|
||||
}
|
||||
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||
|
||||
msg = router.planning_approve("ch-1", "discord")
|
||||
assert "Aprobat" in msg or "✅" in msg
|
||||
|
||||
approved = json.loads(tmp_state["approved"].read_text())
|
||||
entry = approved["projects"][0]
|
||||
assert entry["status"] == "approved"
|
||||
assert entry["approved_at"] is not None
|
||||
assert entry["planning_session_id"] is None
|
||||
assert entry["final_plan_path"] # set
|
||||
|
||||
# Planning state cleared
|
||||
from src.planning_session import is_in_planning
|
||||
assert is_in_planning("discord", "ch-1") is False
|
||||
|
||||
def test_no_state_returns_error_message(self, tmp_state):
|
||||
msg = router.planning_approve("ch-missing", "discord")
|
||||
assert "Nu există" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# planning_cancel via route_message /cancel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRouteMessagePlanningCancel:
|
||||
def test_slash_cancel_in_planning_clears_state(self, tmp_state):
|
||||
# Seed a planning session and approved-tasks pending entry
|
||||
_save_planning_state({
|
||||
_channel_key("discord", "ch-1"): {
|
||||
"slug": "demo",
|
||||
"description": "x",
|
||||
"phase": "/office-hours",
|
||||
"phases_planned": ["/office-hours", "/plan-ceo-review"],
|
||||
"phases_completed": [],
|
||||
"phase_index": 0,
|
||||
"session_id": "s-uuid",
|
||||
"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:00:00+00:00",
|
||||
"last_text_excerpt": "Hi",
|
||||
"last_subtype": "success",
|
||||
}
|
||||
})
|
||||
approved_data = {
|
||||
"projects": [
|
||||
{
|
||||
"name": "demo",
|
||||
"description": "x",
|
||||
"status": "planning",
|
||||
"planning_session_id": "ps-uuid",
|
||||
"final_plan_path": None,
|
||||
"proposed_at": "2026-04-26T18:00:00+00:00",
|
||||
"approved_at": None,
|
||||
"started_at": None,
|
||||
"pid": None,
|
||||
}
|
||||
],
|
||||
"last_updated": None,
|
||||
}
|
||||
tmp_state["approved"].write_text(json.dumps(approved_data))
|
||||
|
||||
response, is_cmd = router.route_message(
|
||||
"ch-1", "user-1", "/cancel", adapter_name="discord"
|
||||
)
|
||||
assert is_cmd is True
|
||||
assert "anulat" in response.lower()
|
||||
approved = json.loads(tmp_state["approved"].read_text())
|
||||
assert approved["projects"][0]["status"] == "pending"
|
||||
from src.planning_session import is_in_planning
|
||||
assert is_in_planning("discord", "ch-1") is False
|
||||
|
||||
|
||||
class TestRouteMessagePlanningRespond:
|
||||
def test_plain_message_in_planning_routes_to_orchestrator(self, tmp_state):
|
||||
# Seed a planning session
|
||||
_save_planning_state({
|
||||
_channel_key("discord", "ch-1"): {
|
||||
"slug": "demo",
|
||||
"description": "x",
|
||||
"phase": "/office-hours",
|
||||
"phases_planned": ["/office-hours"],
|
||||
"phases_completed": [],
|
||||
"phase_index": 0,
|
||||
"session_id": "s-uuid",
|
||||
"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:00:00+00:00",
|
||||
"last_text_excerpt": "Hi",
|
||||
"last_subtype": "success",
|
||||
}
|
||||
})
|
||||
with patch(
|
||||
"src.planning_session._run_claude",
|
||||
return_value=_fake_result(
|
||||
session_id="s-uuid", text="thanks PHASE_STATUS: needs_input"
|
||||
),
|
||||
) as mock_run:
|
||||
response, is_cmd = router.route_message(
|
||||
"ch-1", "user-1", "Vreau așa ceva.", adapter_name="discord"
|
||||
)
|
||||
mock_run.assert_called_once()
|
||||
# respond uses --resume
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--resume" in cmd
|
||||
assert is_cmd is False
|
||||
assert "thanks" in response
|
||||
|
||||
def test_no_planning_state_falls_through_to_normal_routing(self, tmp_state):
|
||||
# No planning state — should go to ralph dispatch / Claude.
|
||||
with patch(
|
||||
"src.router.send_message", return_value="claude says hi"
|
||||
) as mock_send:
|
||||
response, is_cmd = router.route_message(
|
||||
"ch-1", "user-1", "hello",
|
||||
adapter_name="discord",
|
||||
model="sonnet",
|
||||
)
|
||||
mock_send.assert_called_once()
|
||||
assert response == "claude says hi"
|
||||
assert is_cmd is False
|
||||
Reference in New Issue
Block a user