Files
echo-core/src/adapters/discord_views.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

394 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Discord interactive Views for Ralph — root list + per-project actions + propose modal.
Critical pattern: every button callback that does I/O MUST call
`await interaction.response.defer(ephemeral=True)` FIRST, then use
`interaction.followup.send(...)`. Otherwise Discord's 3s interaction
timeout kicks in and the user sees "Interaction failed".
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import discord
from src.router import (
_load_approved_tasks,
_ralph_approve,
_ralph_propose,
_ralph_status,
_ralph_stop,
planning_advance,
planning_approve,
planning_cancel,
start_planning_session,
)
from src.planning_session import is_in_planning
log = logging.getLogger(__name__)
WORKSPACE_DIR = Path("/home/moltbot/workspace")
# Status → emoji prefix for project labels
_STATUS_EMOJI = {
"pending": "📋",
"approved": "",
"running": "🟢",
"complete": "",
"failed": "",
"stopped": "",
}
VIEW_TIMEOUT = 600 # 10 min — matches ralph_flow TTL
def _project_label(name: str, status: str | None) -> str:
"""Return a short button label like '🟢 roa2web' (max 80 chars per Discord)."""
emoji = _STATUS_EMOJI.get(status or "", "·")
label = f"{emoji} {name}"
return label[:80]
def _list_workspace_projects() -> list[str]:
"""Return workspace folder names sorted, skipping hidden dirs."""
if not WORKSPACE_DIR.exists():
return []
return sorted(
p.name for p in WORKSPACE_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
def _project_status(name: str) -> str | None:
"""Look up status of a project from approved-tasks.json (or None)."""
try:
data = _load_approved_tasks()
except Exception:
return None
for p in data.get("projects", []):
if p.get("name", "").lower() == name.lower():
return p.get("status")
return None
def _read_prd(name: str) -> dict | None:
prd_path = WORKSPACE_DIR / name / "scripts" / "ralph" / "prd.json"
if not prd_path.exists():
return None
try:
return json.loads(prd_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return None
# ---------------------------------------------------------------------------
# RalphProposeModal — text input for new feature description
# ---------------------------------------------------------------------------
class RalphProposeModal(discord.ui.Modal, title="Propune feature Ralph"):
"""Modal asking the user for a feature description for a given project."""
description: discord.ui.TextInput = discord.ui.TextInput(
label="Descriere",
placeholder="Ce trebuie făcut? (1-3 propoziții)",
style=discord.TextStyle.paragraph,
required=True,
max_length=1000,
)
def __init__(self, slug: str) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self.slug = slug
self.title = f"Propune feature: {slug}"[:45]
async def on_submit(self, interaction: discord.Interaction) -> None:
try:
result = _ralph_propose(self.slug, str(self.description.value).strip())
except Exception as e:
log.exception("Ralph propose modal failed for %s", self.slug)
await interaction.response.send_message(
f"Eroare la propunere: {e}", ephemeral=True
)
return
await interaction.response.send_message(result, ephemeral=True)
# ---------------------------------------------------------------------------
# RalphProjectView — per-project action buttons
# ---------------------------------------------------------------------------
class RalphProjectView(discord.ui.View):
"""Buttons: Propune feature / Vezi PRD / Start acum / Status / Stop / Înapoi."""
def __init__(self, slug: str) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self.slug = slug
@discord.ui.button(label=" Propune feature", style=discord.ButtonStyle.primary, row=0)
async def propose(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.send_modal(RalphProposeModal(self.slug))
@discord.ui.button(label="👁 Vezi PRD", style=discord.ButtonStyle.secondary, row=0)
async def view_prd(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
prd = _read_prd(self.slug)
if prd is None:
await interaction.followup.send(
f"Nu există PRD pentru {self.slug}. Aprobă cu /a {self.slug} și night-execute îl generează.",
ephemeral=True,
)
return
stories = prd.get("userStories", [])
done = sum(1 for s in stories if s.get("passes"))
total = len(stories)
lines = [f"**PRD pentru {self.slug}** — {done}/{total} stories"]
for s in stories[:12]:
mark = "" if s.get("passes") else ""
sid = s.get("id", "?")
title = (s.get("title") or "")[:80]
lines.append(f"{mark} `{sid}` {title}")
if total > 12:
lines.append(f"\n…și încă {total - 12} stories.")
await interaction.followup.send("\n".join(lines), ephemeral=True)
@discord.ui.button(label="📊 Status", style=discord.ButtonStyle.secondary, row=0)
async def status(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_status(self.slug)
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="✅ Aprobă pentru tonight", style=discord.ButtonStyle.success, row=1)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_approve([self.slug])
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🛑 Stop", style=discord.ButtonStyle.danger, row=1)
async def stop(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_stop(self.slug)
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🧠 Planifică", style=discord.ButtonStyle.primary, row=2)
async def plan(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
# Look up description from approved-tasks.json
description = ""
try:
data = _load_approved_tasks()
for p in data.get("projects", []):
if p.get("name", "").lower() == self.slug.lower():
description = p.get("description") or ""
break
except Exception:
log.exception("approved-tasks lookup failed")
if not description:
await interaction.followup.send(
f"Nu am descriere pentru `{self.slug}`. "
f"Adaugă mai întâi cu `/p {self.slug} <descriere>`.",
ephemeral=True,
)
return
channel_id = str(interaction.channel_id)
await interaction.followup.send(
f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)",
ephemeral=True,
)
try:
first = start_planning_session(
self.slug, description, channel_id, "discord",
)
except Exception as e:
log.exception("start_planning_session failed for %s", self.slug)
await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True)
return
# Send first message of the planning agent + active keyboard
for chunk in _split_chunks(first, 1900):
await interaction.followup.send(chunk, ephemeral=True)
await interaction.followup.send(
"Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.",
view=PlanningActiveView(),
ephemeral=True,
)
@discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=2)
async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphRootView()
content = view.render_summary()
await interaction.edit_original_response(content=content, view=view)
# ---------------------------------------------------------------------------
# RalphRootView — workspace + active projects landing
# ---------------------------------------------------------------------------
class _ProjectButton(discord.ui.Button):
"""Dynamic button representing a single project; opens RalphProjectView."""
def __init__(self, slug: str, status: str | None, row: int) -> None:
super().__init__(
label=_project_label(slug, status),
style=discord.ButtonStyle.secondary,
row=row,
custom_id=f"ralph_project:{slug}",
)
self.slug = slug
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphProjectView(self.slug)
content = f"**{self.slug}**\nAlege o acțiune:"
await interaction.edit_original_response(content=content, view=view)
class _RefreshButton(discord.ui.Button):
def __init__(self) -> None:
super().__init__(
label="🔄 Reîncarcă",
style=discord.ButtonStyle.primary,
row=4,
custom_id="ralph_refresh",
)
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphRootView()
await interaction.edit_original_response(
content=view.render_summary(), view=view
)
class _CloseButton(discord.ui.Button):
def __init__(self) -> None:
super().__init__(
label="❌ Închide",
style=discord.ButtonStyle.danger,
row=4,
custom_id="ralph_close",
)
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
await interaction.edit_original_response(content="Închis.", view=None)
def _split_chunks(text: str, limit: int = 1900) -> list[str]:
"""Split a long message into Discord-safe chunks."""
if len(text) <= limit:
return [text]
chunks: list[str] = []
while text:
if len(text) <= limit:
chunks.append(text)
break
cut = text.rfind("\n", 0, limit)
if cut == -1:
cut = limit
chunks.append(text[:cut])
text = text[cut:].lstrip("\n")
return chunks
# ---------------------------------------------------------------------------
# Planning views (W2) — buttons that drive the planning conversation
# ---------------------------------------------------------------------------
class PlanningActiveView(discord.ui.View):
"""Buttons shown DURING an active planning session: advance phase / cancel."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
@discord.ui.button(label="▶️ Continuă faza", style=discord.ButtonStyle.primary, row=0)
async def advance(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
channel_id = str(interaction.channel_id)
try:
text, completed = planning_advance(channel_id, "discord")
except Exception as e:
log.exception("planning advance failed")
await interaction.followup.send(f"Eroare: {e}", ephemeral=True)
return
for chunk in _split_chunks(text):
await interaction.followup.send(chunk, ephemeral=True)
view: discord.ui.View = (
PlanningFinalView() if completed else PlanningActiveView()
)
await interaction.followup.send(
("Plan gata. Confirmi?" if completed else "Continuăm?"),
view=view, ephemeral=True,
)
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_cancel(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
class PlanningFinalView(discord.ui.View):
"""Buttons shown when ALL planning phases finished — Dau drumul / Anulează."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
@discord.ui.button(label="✅ Dau drumul tonight", style=discord.ButtonStyle.success, row=0)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_approve(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = planning_cancel(str(interaction.channel_id), "discord")
await interaction.followup.send(text, ephemeral=True)
class RalphRootView(discord.ui.View):
"""Landing view: workspace projects with status emoji + refresh + close."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self._build_buttons()
def _build_buttons(self) -> None:
projects = _list_workspace_projects()
# Discord limit: 5 buttons per row × 5 rows. Reserve last row for refresh/close.
# Project buttons go on rows 0..3 (max 20 projects).
for idx, slug in enumerate(projects[:20]):
row = idx // 5
status = _project_status(slug)
self.add_item(_ProjectButton(slug, status, row=row))
self.add_item(_RefreshButton())
self.add_item(_CloseButton())
def render_summary(self) -> str:
"""Build the message text shown above the buttons."""
try:
data = _load_approved_tasks()
except Exception:
data = {"projects": []}
active = [
p for p in data.get("projects", [])
if p.get("status") in ("pending", "approved", "running")
]
lines = ["📋 **Proiecte Ralph**"]
if active:
lines.append("")
lines.append("**Active:**")
for p in active[:10]:
emoji = _STATUS_EMOJI.get(p.get("status", ""), "·")
desc = (p.get("description") or "")[:60]
lines.append(f"{emoji} `{p.get('name')}` — {desc}")
lines.append("")
lines.append("Apasă pe un proiect pentru acțiuni, sau Reîncarcă pentru status fresh.")
return "\n".join(lines)