"""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