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>
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""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
|