diff --git a/agentcli/agent/checkpoints.py b/agentcli/agent/checkpoints.py index 2beb1c6..76c0617 100644 --- a/agentcli/agent/checkpoints.py +++ b/agentcli/agent/checkpoints.py @@ -65,15 +65,21 @@ def record_file_before_write(self, rel_path: str, checkpoint: Checkpoint | None try: if full_path.stat().st_size > MAX_SNAPSHOT_FILE_BYTES: - target_cp.files[norm_path] = FileSnapshot(rel_path=norm_path, exists=True, is_binary=True) + target_cp.files[norm_path] = FileSnapshot( + rel_path=norm_path, exists=True, is_binary=True + ) return text = full_path.read_text(encoding="utf-8", errors="replace") target_cp.files[norm_path] = FileSnapshot(rel_path=norm_path, exists=True, content=text) except OSError: - target_cp.files[norm_path] = FileSnapshot(rel_path=norm_path, exists=True, is_binary=True) + target_cp.files[norm_path] = FileSnapshot( + rel_path=norm_path, exists=True, is_binary=True + ) - def create_checkpoint(self, description: str = "Turn checkpoint", metadata: dict[str, Any] | None = None) -> str: + def create_checkpoint( + self, description: str = "Turn checkpoint", metadata: dict[str, Any] | None = None + ) -> str: """Create a new checkpoint tracking session mutations.""" cp_id = uuid.uuid4().hex[:8] cp = Checkpoint( @@ -139,7 +145,11 @@ def rollback(self, checkpoint_id: str | None = None) -> dict[str, Any]: """Revert all file changes recorded in the specified (or latest) checkpoint.""" cp = self._get_checkpoint(checkpoint_id) if cp is None: - return {"success": False, "error": "No checkpoint available to restore", "reverted_files": []} + return { + "success": False, + "error": "No checkpoint available to restore", + "reverted_files": [], + } reverted: list[str] = [] errors: list[str] = [] diff --git a/agentcli/agent/registry.py b/agentcli/agent/registry.py index fd72603..ffd3d32 100644 --- a/agentcli/agent/registry.py +++ b/agentcli/agent/registry.py @@ -24,11 +24,14 @@ from typing import Any from ..subagents.base import SubAgent, SubAgentResult, SubAgentTask, SubAgentType +from ..subagents.clarification import ClarificationAgent from ..subagents.code_analyzer import CodeAnalyzerAgent from ..subagents.consensus import ConsensusAgent +from ..subagents.diagnostics import DiagnosticsAgent from ..subagents.file_ops import FileOpsAgent from ..subagents.grep_search import GrepSearchAgent from ..subagents.shell import ShellExecutionAgent +from ..subagents.task_manager import TaskManagerAgent from ..subagents.web_fetch import WebFetchAgent from ..subagents.web_search import WebSearchAgent from ..subagents.workspace import WorkspaceAgent @@ -219,6 +222,14 @@ def _register_defaults(self) -> None: self.register(SubAgentType.WORKSPACE.value, lambda: WorkspaceAgent(config=ws_cfg)) consensus_cfg = self._tool_configs.get(SubAgentType.CONSENSUS.value) self.register(SubAgentType.CONSENSUS.value, lambda: ConsensusAgent(config=consensus_cfg)) + task_cfg = self._tool_configs.get(SubAgentType.TASK_MANAGER.value) + self.register(SubAgentType.TASK_MANAGER.value, lambda: TaskManagerAgent(config=task_cfg)) + ask_cfg = self._tool_configs.get(SubAgentType.ASK_QUESTION.value) + self.register(SubAgentType.ASK_QUESTION.value, lambda: ClarificationAgent(config=ask_cfg)) + diag_cfg = self._tool_configs.get(SubAgentType.DIAGNOSTICS_CHECK.value) + self.register( + SubAgentType.DIAGNOSTICS_CHECK.value, lambda: DiagnosticsAgent(config=diag_cfg) + ) @staticmethod def _safe_type(agent_type: str) -> SubAgentType: diff --git a/agentcli/agent/tasks.py b/agentcli/agent/tasks.py new file mode 100644 index 0000000..24bb6ec --- /dev/null +++ b/agentcli/agent/tasks.py @@ -0,0 +1,286 @@ +"""Background task and process management engine. + +Provides async subprocess execution for long-running processes (development +servers, test watchers, daemons), output streaming with circular ring buffers, +status tracking, interactive stdin transmission, and graceful process-group teardown. +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import sys +import time +from collections import deque +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +@dataclass +class BackgroundTask: + """Represents a background subprocess task.""" + + id: str + command: str + cwd: str + status: str = "running" # "running" | "completed" | "failed" | "killed" + start_time: float = field(default_factory=time.time) + end_time: float | None = None + returncode: int | None = None + output_lines: deque[str] = field(default_factory=lambda: deque(maxlen=1000)) + process: asyncio.subprocess.Process | None = None + _reader_task: asyncio.Task[None] | None = None + + @property + def uptime_seconds(self) -> float: + """Calculate task uptime in seconds.""" + if self.end_time is not None: + return max(0.0, self.end_time - self.start_time) + return max(0.0, time.time() - self.start_time) + + def to_summary(self) -> dict[str, Any]: + """Convert task to a lightweight summary dict.""" + last_lines = list(self.output_lines)[-3:] if self.output_lines else [] + return { + "id": self.id, + "command": self.command, + "cwd": self.cwd, + "status": self.status, + "uptime_seconds": round(self.uptime_seconds, 2), + "returncode": self.returncode, + "total_lines": len(self.output_lines), + "output_preview": last_lines, + } + + def to_detail(self) -> dict[str, Any]: + """Convert task to detailed status dict.""" + return { + "id": self.id, + "command": self.command, + "cwd": self.cwd, + "status": self.status, + "start_time": datetime.fromtimestamp(self.start_time, tz=UTC).isoformat(), + "end_time": ( + datetime.fromtimestamp(self.end_time, tz=UTC).isoformat() if self.end_time else None + ), + "uptime_seconds": round(self.uptime_seconds, 2), + "returncode": self.returncode, + "total_lines": len(self.output_lines), + "pid": self.process.pid if self.process else None, + } + + +class TaskManager: + """Manages asynchronous background tasks for the agent session.""" + + def __init__(self, root_dir: str | Path | None = None) -> None: + self.root_dir = Path(root_dir).resolve() if root_dir else Path.cwd() + self._tasks: dict[str, BackgroundTask] = {} + self._counter: int = 0 + + + def _generate_id(self) -> str: + self._counter += 1 + return f"task_{self._counter}" + + async def start_task( + self, + command: str, + cwd: str | Path | None = None, + ) -> BackgroundTask: + """Start a new command in the background. + + Args: + command: Shell command string to execute. + cwd: Working directory (defaults to root_dir). + + Returns: + The created BackgroundTask instance. + """ + work_dir = Path(cwd).resolve() if cwd else self.root_dir + if not work_dir.exists(): + work_dir.mkdir(parents=True, exist_ok=True) + + task_id = self._generate_id() + task = BackgroundTask( + id=task_id, + command=command, + cwd=str(work_dir), + status="running", + ) + + creationflags = 0 + if sys.platform == "win32": + # Start in a new process group for clean subtree termination + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + + try: + process = await asyncio.create_subprocess_shell( + command, + cwd=str(work_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE, + creationflags=creationflags, + ) + task.process = process + except Exception as exc: # noqa: BLE001 + task.status = "failed" + task.end_time = time.time() + task.returncode = -1 + task.output_lines.append(f"Failed to start task: {exc}") + self._tasks[task_id] = task + logger.error("TaskManager: failed to start task %s: %s", task_id, exc) + return task + + # Start streaming reader coroutine + task._reader_task = asyncio.create_task(self._read_stream(task)) + self._tasks[task_id] = task + logger.info( + "TaskManager: started background task %s (pid %s): %s", task_id, process.pid, command + ) + return task + + async def _read_stream(self, task: BackgroundTask) -> None: + """Continuously read process stdout/stderr and capture into buffer.""" + proc = task.process + if proc is None or proc.stdout is None: + return + + try: + while not proc.stdout.at_eof(): + line_bytes = await proc.stdout.readline() + if not line_bytes: + break + line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n") + task.output_lines.append(line) + except asyncio.CancelledError: + pass + except Exception as exc: # noqa: BLE001 + task.output_lines.append(f"[Stream reader error: {exc}]") + finally: + if proc.returncode is None: + try: + await proc.wait() + except Exception: # noqa: BLE001, S110 + pass + task.end_time = time.time() + task.returncode = proc.returncode + if task.status != "killed": + task.status = "completed" if proc.returncode == 0 else "failed" + + def list_tasks(self) -> list[dict[str, Any]]: + """List summaries of all tracked background tasks.""" + return [t.to_summary() for t in self._tasks.values()] + + def get_task(self, task_id: str) -> BackgroundTask | None: + """Get BackgroundTask by ID.""" + return self._tasks.get(task_id) + + def get_status(self, task_id: str) -> dict[str, Any]: + """Get detailed status of a task.""" + task = self.get_task(task_id) + if not task: + return {"error": f"Task not found: {task_id}", "status": "unknown"} + return task.to_detail() + + def get_logs( + self, + task_id: str, + tail: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + """Get log lines from a background task.""" + task = self.get_task(task_id) + if not task: + return {"error": f"Task not found: {task_id}", "lines": []} + + all_lines = list(task.output_lines) + total = len(all_lines) + + if offset > 0: + selected = all_lines[offset : offset + tail] + else: + selected = all_lines[-tail:] if tail > 0 else all_lines + + return { + "task_id": task_id, + "status": task.status, + "total_lines": total, + "returncode": task.returncode, + "lines": selected, + } + + async def send_input(self, task_id: str, input_text: str) -> dict[str, Any]: + """Write input text into stdin of a running task.""" + task = self.get_task(task_id) + if not task: + return {"success": False, "error": f"Task not found: {task_id}"} + if task.status != "running" or task.process is None or task.process.stdin is None: + return { + "success": False, + "error": f"Task {task_id} is not running (status: {task.status})", + } + + try: + if not input_text.endswith("\n"): + input_text += "\n" + task.process.stdin.write(input_text.encode("utf-8")) + await task.process.stdin.drain() + return {"success": True, "task_id": task_id} + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": f"Failed to send stdin to task {task_id}: {exc}"} + + async def kill_task(self, task_id: str) -> dict[str, Any]: + """Terminate a running background task.""" + task = self.get_task(task_id) + if not task: + return {"success": False, "error": f"Task not found: {task_id}"} + + proc = task.process + if proc is None or task.status != "running": + return {"success": True, "message": f"Task {task_id} was already {task.status}"} + + task.status = "killed" + task.end_time = time.time() + + try: + if sys.platform == "win32" and proc.pid: + # Force kill process tree on Windows + subprocess.run( # noqa: ASYNC221 + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2.0) + except TimeoutError: + proc.kill() + except Exception as exc: # noqa: BLE001 + logger.warning("TaskManager: error terminating task %s: %s", task_id, exc) + + if task._reader_task and not task._reader_task.done(): + task._reader_task.cancel() + + return {"success": True, "task_id": task_id, "status": "killed"} + + async def cleanup_all(self) -> None: + """Kill all running background tasks (called on session shutdown).""" + running_tasks = [t for t in self._tasks.values() if t.status == "running"] + for task in running_tasks: + try: + await self.kill_task(task.id) + except Exception as exc: # noqa: BLE001 + logger.warning("TaskManager: cleanup error for task %s: %s", task.id, exc) diff --git a/agentcli/cli.py b/agentcli/cli.py index dc71fe3..9769935 100644 --- a/agentcli/cli.py +++ b/agentcli/cli.py @@ -701,9 +701,7 @@ async def run_chat(args: argparse.Namespace, config: Config) -> int: ("/models", "/model list", "/model free", "/model paid") ): filter_type = ( - "free" - if "free" in user_input - else ("paid" if "paid" in user_input else None) + "free" if "free" in user_input else ("paid" if "paid" in user_input else None) ) active_curr = forced_model or "auto" if session.registry is None: @@ -799,7 +797,8 @@ async def run_chat(args: argparse.Namespace, config: Config) -> int: is_free = target_model.endswith(":free") or ( session.registry and any( - m.id == target_model and m.is_free for m in session.registry.all_models() + m.id == target_model and m.is_free + for m in session.registry.all_models() ) ) badge = "[FREE]" if is_free else "[PAID]" @@ -850,21 +849,79 @@ async def run_chat(args: argparse.Namespace, config: Config) -> int: else: print("\nAvailable Checkpoints:") for cp in cps: - print(f" [{cp['id']}] {cp['description']} ({cp['file_count']} file(s) tracked)") + print( + f" [{cp['id']}] {cp['description']} ({cp['file_count']} file(s) tracked)" + ) else: rollback_res = session.checkpoint_manager.rollback() if rollback_res["success"]: reverted = rollback_res.get("reverted_files", []) if reverted: - print(f"Rollback successful (Checkpoint {rollback_res.get('checkpoint_id')}). Reverted {len(reverted)} file(s):") + print( + f"Rollback successful (Checkpoint {rollback_res.get('checkpoint_id')}). Reverted {len(reverted)} file(s):" + ) for f in reverted: print(f" - {f}") else: - print(f"Rollback completed. No modified files to revert for checkpoint {rollback_res.get('checkpoint_id')}.") + print( + f"Rollback completed. No modified files to revert for checkpoint {rollback_res.get('checkpoint_id')}." + ) else: print(f"Rollback failed: {rollback_res.get('error', 'Unknown error')}") continue + if user_input.startswith(("/tasks", "/task", "/bg")): + task_parts = user_input.split(maxsplit=2) + task_subcmd = task_parts[1].lower() if len(task_parts) > 1 else "list" + target_id = task_parts[2].strip() if len(task_parts) > 2 else "" + + if task_subcmd in ("list", "ls") or (len(task_parts) == 1): + all_tasks = session.task_manager.list_tasks() + if not all_tasks: + print("No background tasks currently active.") + else: + print(f"\n--- Background Tasks ({len(all_tasks)}) ---") + for t in all_tasks: + print( + f" [{t['id']}] {t['command']} (status: {t['status']}, uptime: {t['uptime_seconds']}s, lines: {t['total_lines']})" + ) + print("------------------------------------\n") + elif task_subcmd == "status": + if not target_id: + print("Usage: /tasks status ") + else: + status_info = session.task_manager.get_status(target_id) + if "error" in status_info: + print(f"Error: {status_info['error']}") + else: + print(f"\nTask [{target_id}] Status:") + for k, v in status_info.items(): + print(f" {k}: {v}") + elif task_subcmd in ("logs", "log"): + if not target_id: + print("Usage: /tasks logs [tail]") + else: + log_info = session.task_manager.get_logs(target_id, tail=30) + if "error" in log_info: + print(f"Error: {log_info['error']}") + else: + print(f"\n--- Logs for [{target_id}] ({log_info['status']}) ---") + for line in log_info.get("lines", []): + print(line) + print("------------------------------------\n") + elif task_subcmd in ("kill", "stop"): + if not target_id: + print("Usage: /tasks kill ") + else: + kill_res = await session.task_manager.kill_task(target_id) + if kill_res.get("success"): + print(f"Task [{target_id}] terminated.") + else: + print(f"Failed to terminate [{target_id}]: {kill_res.get('error')}") + else: + print("Usage: /tasks [list | status | logs | kill ]") + continue + if user_input in {"/clear", "/cls"}: renderer.clear() continue diff --git a/agentcli/routing/registry.py b/agentcli/routing/registry.py index 18d5c49..90fb683 100644 --- a/agentcli/routing/registry.py +++ b/agentcli/routing/registry.py @@ -65,7 +65,11 @@ def format_models_text( lines.append("-" * 76) for m in filtered: - status = "● ACTIVE" if active_model and (active_model == m.id or (active_model == "auto" and m.is_free)) else "" + status = ( + "● ACTIVE" + if active_model and (active_model == m.id or (active_model == "auto" and m.is_free)) + else "" + ) type_tag = "[FREE]" if m.is_free else "[PAID]" tier_tag = m.tier.upper() ctx = f"{m.context_window // 1000}k" if m.context_window >= 1000 else str(m.context_window) diff --git a/agentcli/session.py b/agentcli/session.py index fb2a837..9290c8f 100644 --- a/agentcli/session.py +++ b/agentcli/session.py @@ -11,6 +11,7 @@ from .agent.events import LoopEvent from .agent.loop import AgentLoop, is_agentic_task from .agent.registry import ToolRegistry +from .agent.tasks import TaskManager from .config import Config from .files import load_agents_md from .mcp.manager import MCPClientManager @@ -109,6 +110,7 @@ def __init__( self.router: Router | None = None self.mcp_manager: MCPClientManager = MCPClientManager(config=self.config) self.checkpoint_manager: CheckpointManager = CheckpointManager() + self.task_manager: TaskManager = TaskManager() if config.routing.enabled: self.registry = ModelRegistry(config.routing) @@ -146,6 +148,8 @@ def close(self) -> None: async def aclose(self) -> None: await self.client.aclose() await self.mcp_manager.aclose() + if hasattr(self, "task_manager") and self.task_manager is not None: + await self.task_manager.cleanup_all() self.close() def __del__(self) -> None: diff --git a/agentcli/subagents/__init__.py b/agentcli/subagents/__init__.py index bb39e26..e22677d 100644 --- a/agentcli/subagents/__init__.py +++ b/agentcli/subagents/__init__.py @@ -6,23 +6,30 @@ from .base import SubAgent, SubAgentResult, SubAgentTask, SubAgentType from .bus import Message, MessageBus, MessageType +from .clarification import ClarificationAgent from .code_analyzer import CodeAnalyzerAgent from .consensus import AgentVote, ConsensusEngine, ConsensusResult, ConsensusStrategy +from .diagnostics import DiagnosticsAgent, DiagnosticSpan, DiagnosticsParser from .file_ops import FileOpsAgent from .grep_search import GrepSearchAgent from .planner import PlannerAgent from .shell import ShellExecutionAgent from .spawner import SubAgentPool, SubAgentSpawner +from .task_manager import TaskManagerAgent from .web_fetch import HTMLToMarkdownConverter, WebFetchAgent, html_to_markdown from .web_search import WebSearchAgent from .workspace import WorkspaceAgent __all__ = [ "AgentVote", + "ClarificationAgent", "CodeAnalyzerAgent", "ConsensusEngine", "ConsensusResult", "ConsensusStrategy", + "DiagnosticSpan", + "DiagnosticsAgent", + "DiagnosticsParser", "FileOpsAgent", "GrepSearchAgent", "HTMLToMarkdownConverter", @@ -37,6 +44,7 @@ "SubAgentSpawner", "SubAgentTask", "SubAgentType", + "TaskManagerAgent", "WebFetchAgent", "WebSearchAgent", "WorkspaceAgent", diff --git a/agentcli/subagents/base.py b/agentcli/subagents/base.py index 984ad7c..20ba286 100644 --- a/agentcli/subagents/base.py +++ b/agentcli/subagents/base.py @@ -37,6 +37,9 @@ class SubAgentType(str, Enum): PLANNER = "planner" WORKSPACE = "workspace" CONSENSUS = "consensus" + TASK_MANAGER = "task_manager" + ASK_QUESTION = "ask_question" + DIAGNOSTICS_CHECK = "diagnostics_check" class SubAgentStatus(str, Enum): diff --git a/agentcli/subagents/clarification.py b/agentcli/subagents/clarification.py new file mode 100644 index 0000000..2d5b791 --- /dev/null +++ b/agentcli/subagents/clarification.py @@ -0,0 +1,114 @@ +"""Interactive user disambiguation and clarification subagent.""" + +from __future__ import annotations + +import inspect +import logging +from collections.abc import Callable +from typing import Any + +from .base import SubAgent, SubAgentResult, SubAgentTask, SubAgentType + +logger = logging.getLogger(__name__) + +# Type for interactive callback: (question, options, is_multi_select) -> answer +ClarificationHandler = Callable[[str, list[str] | None, bool], Any] + + +class ClarificationAgent(SubAgent): + """Sub-agent responsible for asking clarifying questions to the user during tasks.""" + + def __init__( + self, + config: dict[str, Any] | None = None, + handler: ClarificationHandler | None = None, + ) -> None: + super().__init__( + agent_type=SubAgentType.ASK_QUESTION, + config=config or {}, + ) + self.handler = handler + + def set_handler(self, handler: ClarificationHandler) -> None: + """Register the interactive UI/CLI prompt handler.""" + self.handler = handler + + async def run(self, task: SubAgentTask) -> SubAgentResult: + """Execute clarification question.""" + payload = task.payload + + question = payload.get("question") or payload.get("prompt") + options = payload.get("options") + is_multi_select = bool(payload.get("is_multi_select", False)) + context = payload.get("context", "") + + if not question: + # Check if questions array was passed + questions = payload.get("questions") + if isinstance(questions, list) and questions: + first = questions[0] + question = first.get("question") + options = first.get("options", options) + is_multi_select = bool(first.get("is_multi_select", is_multi_select)) + + if not question: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'question' in clarification payload", + ) + + # Normalize options + if isinstance(options, list): + options = [str(opt) for opt in options if opt is not None] + else: + options = None + + logger.info("ClarificationAgent: asking user question: %s", question) + + # If interactive handler is present, call it + if self.handler is not None: + try: + if inspect.iscoroutinefunction(self.handler): + answer = await self.handler(question, options, is_multi_select) + else: + answer = self.handler(question, options, is_multi_select) + + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=True, + output={ + "question": question, + "options": options, + "answer": answer, + "interactive": True, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("ClarificationAgent handler raised error: %s", exc) + + # Non-interactive fallback (CI, batch scripts, --plain mode) + selected_default = None + if options: + # Look for recommendation prefix or take first + recommended = next((o for o in options if "(recommended)" in o.lower()), options[0]) + selected_default = [recommended] if is_multi_select else recommended + answer_text = f"[Non-interactive mode: auto-selected default '{selected_default}']" + else: + answer_text = "[Non-interactive mode: proceeding with standard defaults]" + + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=True, + output={ + "question": question, + "options": options, + "answer": answer_text, + "selected": selected_default, + "interactive": False, + "context": context, + }, + ) diff --git a/agentcli/subagents/diagnostics.py b/agentcli/subagents/diagnostics.py new file mode 100644 index 0000000..ea9d06e --- /dev/null +++ b/agentcli/subagents/diagnostics.py @@ -0,0 +1,5 @@ +"""Diagnostics subagent module re-exporting DiagnosticsAgent.""" + +from ..tools.diagnostics import DiagnosticsAgent, DiagnosticSpan, DiagnosticsParser + +__all__ = ["DiagnosticSpan", "DiagnosticsAgent", "DiagnosticsParser"] diff --git a/agentcli/subagents/grep_search.py b/agentcli/subagents/grep_search.py index 0416caa..b761b54 100644 --- a/agentcli/subagents/grep_search.py +++ b/agentcli/subagents/grep_search.py @@ -176,7 +176,9 @@ async def _run_ripgrep( match_data = data.get("data", {}) path_text = match_data.get("path", {}).get("text", "") try: - rel_file = str(Path(path_text).relative_to(self.working_dir)).replace("\\", "/") + rel_file = str(Path(path_text).relative_to(self.working_dir)).replace( + "\\", "/" + ) except ValueError: rel_file = path_text.replace("\\", "/") @@ -257,7 +259,9 @@ def should_include_file(file_name: str, rel_path: str) -> bool: else: file_candidates = [] for root, dirs, files in os.walk(target_path): - dirs[:] = [d for d in dirs if d not in DEFAULT_IGNORES and not d.endswith(".egg-info")] + dirs[:] = [ + d for d in dirs if d not in DEFAULT_IGNORES and not d.endswith(".egg-info") + ] for f in files: file_candidates.append(Path(root, f)) diff --git a/agentcli/subagents/task_manager.py b/agentcli/subagents/task_manager.py new file mode 100644 index 0000000..a5903f1 --- /dev/null +++ b/agentcli/subagents/task_manager.py @@ -0,0 +1,161 @@ +"""TaskManager subagent wrapping background task execution and lifecycle control.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from .base import SubAgent, SubAgentResult, SubAgentTask, SubAgentType + +if TYPE_CHECKING: + from ..agent.tasks import TaskManager + +logger = logging.getLogger(__name__) + + +class TaskManagerAgent(SubAgent): + """Sub-agent responsible for managing background commands and processes.""" + + def __init__( + self, + config: dict[str, Any] | None = None, + task_manager: TaskManager | None = None, + ) -> None: + super().__init__( + agent_type=SubAgentType.TASK_MANAGER, + config=config or {}, + ) + if task_manager is not None: + self.task_manager = task_manager + else: + from ..agent.tasks import TaskManager + + self.task_manager = TaskManager(root_dir=self.config.get("working_dir")) + + async def run(self, task: SubAgentTask) -> SubAgentResult: + """Execute task manager action.""" + payload = task.payload + action = payload.get("action", "list").lower() + + try: + if action in ("run", "start", "run_background"): + command = payload.get("command") + if not command: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'command' in payload", + ) + cwd = payload.get("cwd") or payload.get("working_dir") + bg_task = await self.task_manager.start_task(command=command, cwd=cwd) + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=bg_task.status != "failed", + output=bg_task.to_detail(), + error=bg_task.output_lines[0] if bg_task.status == "failed" else None, + ) + + elif action == "list": + tasks_list = self.task_manager.list_tasks() + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=True, + output={"tasks": tasks_list, "total": len(tasks_list)}, + ) + + elif action == "status": + task_id = payload.get("task_id") + if not task_id: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'task_id' in payload", + ) + status_info = self.task_manager.get_status(task_id) + success = "error" not in status_info + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=success, + output=status_info, + error=status_info.get("error"), + ) + + elif action in ("logs", "log"): + task_id = payload.get("task_id") + if not task_id: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'task_id' in payload", + ) + tail = int(payload.get("tail", 50)) + offset = int(payload.get("offset", 0)) + log_info = self.task_manager.get_logs(task_id, tail=tail, offset=offset) + success = "error" not in log_info + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=success, + output=log_info, + error=log_info.get("error"), + ) + + elif action in ("send_input", "input"): + task_id = payload.get("task_id") + input_text = payload.get("input", "") + if not task_id: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'task_id' in payload", + ) + res = await self.task_manager.send_input(task_id, input_text) + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=res.get("success", False), + output=res, + error=res.get("error"), + ) + + elif action in ("kill", "stop", "terminate"): + task_id = payload.get("task_id") + if not task_id: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error="Missing required 'task_id' in payload", + ) + res = await self.task_manager.kill_task(task_id) + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=res.get("success", False), + output=res, + error=res.get("error"), + ) + + else: + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error=f"Unknown task manager action '{action}'. Supported: run, list, status, logs, send_input, kill", + ) + + except Exception as exc: + logger.exception("TaskManagerAgent execution failed") + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error=str(exc), + ) diff --git a/agentcli/subagents/web_fetch.py b/agentcli/subagents/web_fetch.py index f1e65e8..59461b8 100644 --- a/agentcli/subagents/web_fetch.py +++ b/agentcli/subagents/web_fetch.py @@ -24,7 +24,9 @@ DEFAULT_TIMEOUT_SECONDS = 15.0 DEFAULT_MAX_BYTES = 50_000 -USER_AGENT = "AgentCLI/2.13.0 (Autonomous Developer Assistant; +https://github.com/De-pitcher/agentcli)" +USER_AGENT = ( + "AgentCLI/2.13.0 (Autonomous Developer Assistant; +https://github.com/De-pitcher/agentcli)" +) class HTMLToMarkdownConverter(HTMLParser): diff --git a/agentcli/tools/diagnostics.py b/agentcli/tools/diagnostics.py new file mode 100644 index 0000000..374fe54 --- /dev/null +++ b/agentcli/tools/diagnostics.py @@ -0,0 +1,306 @@ +"""Compiler, linter, and test diagnostics extraction and parser. + +Extracts structured error spans (file, line, column, rule ID, severity, message) +from compiler, linter, and test outputs (pytest, ruff, mypy, tsc, eslint, cargo, gcc). +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from ..subagents.base import SubAgent, SubAgentResult, SubAgentTask, SubAgentType + +logger = logging.getLogger(__name__) + + +@dataclass +class DiagnosticSpan: + """Represents a single structured compiler/linter error span.""" + + file: str + line: int | None = None + column: int | None = None + severity: str = "error" # "error" | "warning" | "note" + code: str | None = None + message: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class DiagnosticsParser: + """Parses raw text outputs from various compilers, linters, and test runners.""" + + # Ruff / Flake8: file.py:12:5: E501 line too long + RUFF_PATTERN = re.compile( + r"^(?P[^:\n\r]+):(?P\d+):(?P\d+):\s*(?P[A-Z0-9]+)\s*(?P.+)$", + re.MULTILINE, + ) + + # Mypy: file.py:42: error: Incompatible types [arg-type] + MYPY_PATTERN = re.compile( + r"^(?P[^:\n\r]+):(?P\d+):\s*(?Perror|warning|note):\s*(?P.+?)(?:\s*\[(?P[^\]]+)\])?$", + re.MULTILINE, + ) + + # TypeScript (tsc): src/index.ts(15,22): error TS2322: Message OR src/index.ts:15:22 - error TS2322: Message + TSC_PATTERN = re.compile( + r"^(?P[^(\n\r:]+)(?:\((?P\d+),(?P\d+)\)|:(?P\d+):(?P\d+))\s*[-:]\s*(?Perror|warning)\s+(?PTS\d+):\s*(?P.+)$", + re.MULTILINE, + ) + + # ESLint: file.js: line 12, col 5, Error - Message (rule) OR file.js:12:5: error: Message [rule] + ESLINT_PATTERN = re.compile( + r"^(?P[^:\n\r]+):\s*(?:line\s*)?(?P\d+)(?::(?P\d+)|\s*,\s*col\s*(?P\d+))\s*[-:,]\s*(?Perror|warning)\s*[-:]?\s*(?P.+?)(?:\s*[\(\[](?P[^\)\]]+)[\)\]])?$", + re.MULTILINE | re.IGNORECASE, + ) + + # Rust (cargo/rustc): error[E0425]: cannot find value --> src/main.rs:12:5 + RUST_SPAN_PATTERN = re.compile( + r"(?:error|warning)(?:\[(?P[A-Z0-9]+)\])?:\s*(?P.+?)\n\s*-->\s*(?P[^:\n\r]+):(?P\d+):(?P\d+)", + re.MULTILINE, + ) + + # Pytest failures: FAILED tests/test_foo.py::test_bar - AssertionError: msg + PYTEST_SUMMARY_PATTERN = re.compile( + r"^FAILED\s+(?P[^:\n\r]+)::(?P[^\s-]+)(?:\s*-\s*(?P.+))?$", + re.MULTILINE, + ) + + # Pytest traceback line: tests/test_foo.py:42: AssertionError + PYTEST_LOCATION_PATTERN = re.compile( + r"^(?P[^:\n\r]+\.py):(?P\d+):\s*(?P.+)$", + re.MULTILINE, + ) + + # Generic: file:line:col: severity: msg or file:line: severity: msg + GENERIC_PATTERN = re.compile( + r"^(?P[a-zA-Z0-9_./\\-]+\.[a-zA-Z0-9]+):(?P\d+)(?::(?P\d+))?:\s*(?:(?Perror|warning|note|fatal)\s*:\s*)?(?P.+)$", + re.MULTILINE | re.IGNORECASE, + ) + + @classmethod + def parse(cls, text: str, framework: str = "auto") -> list[DiagnosticSpan]: + """Parse raw text output into structured DiagnosticSpan records.""" + if not text: + return [] + + framework = framework.lower() + spans: list[DiagnosticSpan] = [] + + if framework in ("pytest", "auto"): + pytest_spans = cls._parse_pytest(text) + if pytest_spans: + spans.extend(pytest_spans) + + if framework in ("ruff", "flake8", "auto"): + for m in cls.RUFF_PATTERN.finditer(text): + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(m.group("line")), + column=int(m.group("col")), + severity="error", + code=m.group("code"), + message=m.group("msg").strip(), + ) + ) + + if framework in ("mypy", "pyright", "auto"): + for m in cls.MYPY_PATTERN.finditer(text): + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(m.group("line")), + severity=m.group("severity").lower(), + code=m.group("code"), + message=m.group("msg").strip(), + ) + ) + + if framework in ("tsc", "typescript", "auto"): + for m in cls.TSC_PATTERN.finditer(text): + line = m.group("line") or m.group("line2") + col = m.group("col") or m.group("col2") + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(line) if line else None, + column=int(col) if col else None, + severity=m.group("severity").lower(), + code=m.group("code"), + message=m.group("msg").strip(), + ) + ) + + if framework in ("eslint", "auto"): + for m in cls.ESLINT_PATTERN.finditer(text): + col = m.group("col") or m.group("col2") + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(m.group("line")), + column=int(col) if col else None, + severity=m.group("severity").lower(), + code=m.group("code"), + message=m.group("msg").strip(), + ) + ) + + if framework in ("rust", "cargo", "rustc", "auto"): + for m in cls.RUST_SPAN_PATTERN.finditer(text): + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(m.group("line")), + column=int(m.group("col")), + severity="error", + code=m.group("code"), + message=m.group("msg").strip(), + ) + ) + + # If auto and nothing found yet, try generic pattern + if not spans: + for m in cls.GENERIC_PATTERN.finditer(text): + spans.append( + DiagnosticSpan( + file=m.group("file").strip(), + line=int(m.group("line")), + column=int(m.group("col")) if m.group("col") else None, + severity=(m.group("severity") or "error").lower(), + message=m.group("msg").strip(), + ) + ) + + # Deduplicate spans while preserving ordering + seen = set() + deduped: list[DiagnosticSpan] = [] + for s in spans: + key = (s.file, s.line, s.column, s.code, s.message) + if key not in seen: + seen.add(key) + deduped.append(s) + + return deduped + + @classmethod + def _parse_pytest(cls, text: str) -> list[DiagnosticSpan]: + """Extract test failure locations from pytest output.""" + spans: list[DiagnosticSpan] = [] + + # Find location mappings: file.py:42: Error + loc_map: dict[str, tuple[int, str]] = {} + for m in cls.PYTEST_LOCATION_PATTERN.finditer(text): + f = m.group("file") + loc_map[f] = (int(m.group("line")), m.group("msg").strip()) + + # Match FAILED lines + for m in cls.PYTEST_SUMMARY_PATTERN.finditer(text): + file_path = m.group("file").strip() + test_name = m.group("test").strip() + msg = m.group("msg") or f"Test failed: {test_name}" + + line, detail_msg = loc_map.get(file_path, (None, "")) + full_msg = f"{test_name}: {msg}" + if detail_msg and detail_msg not in full_msg: + full_msg += f" ({detail_msg})" + + spans.append( + DiagnosticSpan( + file=file_path, + line=line, + severity="error", + code="pytest_failure", + message=full_msg, + ) + ) + + return spans + + +class DiagnosticsAgent(SubAgent): + """Sub-agent responsible for running compiler/linter checks and extracting structured diagnostics.""" + + def __init__(self, config: dict[str, Any] | None = None) -> None: + super().__init__( + agent_type=SubAgentType.DIAGNOSTICS_CHECK, + config=config or {}, + ) + + async def run(self, task: SubAgentTask) -> SubAgentResult: + """Execute diagnostics check.""" + payload = task.payload + command = payload.get("command") + raw_output = payload.get("output", "") + framework = payload.get("framework", "auto") + cwd = payload.get("cwd") or payload.get("working_dir") or self.config.get("working_dir") + + exit_code = 0 + + # If a command is given, execute it asynchronously + if command: + work_dir = Path(cwd).resolve() if cwd else Path.cwd() + try: + proc = await asyncio.create_subprocess_shell( + command, + cwd=str(work_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout_bytes, _ = await proc.communicate() + raw_output = stdout_bytes.decode("utf-8", errors="replace") + exit_code = proc.returncode or 0 + except Exception as exc: # noqa: BLE001 + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=False, + error=f"Failed to execute diagnostics command '{command}': {exc}", + ) + + # Parse diagnostics + spans = DiagnosticsParser.parse(raw_output, framework=framework) + + errors_count = sum(1 for s in spans if s.severity == "error") + warnings_count = sum(1 for s in spans if s.severity == "warning") + + # Build concise markdown summary + if not spans: + if exit_code == 0: + summary = "✅ All diagnostics passed with 0 errors and 0 warnings." + else: + summary = f"Command exited with code {exit_code}, but no standard diagnostic spans were parsed.\nOutput:\n{raw_output[:500]}" + else: + summary_lines = [f"Found {errors_count} error(s) and {warnings_count} warning(s):"] + for s in spans[:20]: + loc = f"{s.file}" + if s.line is not None: + loc += f":{s.line}" + if s.column is not None: + loc += f":{s.column}" + code_str = f" [{s.code}]" if s.code else "" + summary_lines.append(f"- **{loc}** ({s.severity.upper()}{code_str}): {s.message}") + if len(spans) > 20: + summary_lines.append(f"... and {len(spans) - 20} more issues.") + summary = "\n".join(summary_lines) + + return SubAgentResult( + task_id=task.id, + agent_type=self.agent_type, + success=True, + output={ + "command": command, + "exit_code": exit_code, + "errors_count": errors_count, + "warnings_count": warnings_count, + "total_diagnostics": len(spans), + "diagnostics": [s.to_dict() for s in spans], + "summary": summary, + }, + ) diff --git a/agentcli/tools_schema.py b/agentcli/tools_schema.py index b4ac839..18fc530 100644 --- a/agentcli/tools_schema.py +++ b/agentcli/tools_schema.py @@ -297,6 +297,104 @@ }, }, }, + SubAgentType.TASK_MANAGER.value: { + "type": "function", + "function": { + "name": "manage_task", + "description": "Manage background processes and daemon tasks: run/start, list, status, logs, send_input, or kill.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["run", "list", "status", "logs", "send_input", "kill"], + "description": "Action to perform on background task(s)", + }, + "command": { + "type": "string", + "description": "Command to run in the background (required for action='run')", + }, + "task_id": { + "type": "string", + "description": "Task ID to inspect, send input to, or kill (e.g. 'task_1')", + }, + "cwd": { + "type": "string", + "description": "Working directory for the background task", + }, + "input": { + "type": "string", + "description": "Input text to write to stdin of the task (for action='send_input')", + }, + "tail": { + "type": "integer", + "description": "Number of recent log lines to retrieve (default: 50)", + }, + }, + "required": ["action"], + }, + }, + }, + SubAgentType.ASK_QUESTION.value: { + "type": "function", + "function": { + "name": "ask_question", + "description": "Prompt the user for clarification, confirmation, or to select from multiple proposed options.", + "parameters": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Clarifying question or prompt to present to the user", + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of multiple choice options for the user", + }, + "is_multi_select": { + "type": "boolean", + "description": "Whether the user can select multiple options (default: false)", + }, + "context": { + "type": "string", + "description": "Additional context or background explaining why clarification is needed", + }, + }, + "required": ["question"], + }, + }, + }, + SubAgentType.DIAGNOSTICS_CHECK.value: { + "type": "function", + "function": { + "name": "diagnostics_check", + "description": "Run linters, compilers, or test suites and extract structured diagnostic error spans for automated self-repair.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to run and check diagnostics for (e.g. 'pytest', 'ruff check .', 'mypy .')", + }, + "output": { + "type": "string", + "description": "Raw compiler/linter output text to parse directly without running a subprocess", + }, + "framework": { + "type": "string", + "enum": ["auto", "pytest", "ruff", "mypy", "tsc", "eslint", "cargo"], + "default": "auto", + "description": "Framework parser format hint", + }, + "cwd": { + "type": "string", + "description": "Working directory", + }, + }, + }, + }, + }, } diff --git a/agentcli/ui/prompt.py b/agentcli/ui/prompt.py index 3ad2fe2..4c99ab4 100644 --- a/agentcli/ui/prompt.py +++ b/agentcli/ui/prompt.py @@ -51,6 +51,8 @@ def resolve_slash_command(text: str) -> str: "/h": "/help", "/rollback": "/undo", "/revert": "/undo", + "/task": "/tasks", + "/bg": "/tasks", } if raw_cmd in aliases: @@ -65,6 +67,7 @@ def resolve_slash_command(text: str) -> str: "/goal", "/diff", "/undo", + "/tasks", "/tokens", "/cost", "/clear", @@ -83,7 +86,7 @@ def resolve_slash_command(text: str) -> str: class SlashAndFileCompleter(Completer): - """Completer for slash commands (/models, /model, /undo, /budget, /history, /exit, etc.), model arguments, and @file references.""" + """Completer for slash commands (/models, /model, /undo, /tasks, /budget, /history, /exit, etc.), model arguments, and @file references.""" SLASH_COMMANDS: ClassVar[list[tuple[str, str]]] = [ ("/help", "Show help, slash commands, and shortcuts"), @@ -94,6 +97,7 @@ class SlashAndFileCompleter(Completer): ("/goal", "Run an autonomous multi-step goal loop"), ("/diff", "Inspect file diffs generated during session"), ("/undo", "Revert latest file changes or inspect turn rollback (/undo diff)"), + ("/tasks", "List or manage background tasks (/tasks, /tasks kill )"), ("/tokens", "Show current session token usage breakdown"), ("/cost", "Show current session estimated cost"), ("/clear", "Clear terminal screen"), @@ -108,6 +112,8 @@ class SlashAndFileCompleter(Completer): "/diffs": "/diff", "/cls": "/clear", "/h": "/help", + "/task": "/tasks", + "/bg": "/tasks", } def __init__(self) -> None: @@ -135,7 +141,11 @@ def get_completions(self, document: Document, complete_event: CompleteEvent) -> for m in _BUILTIN_MODELS: if m.id.lower().startswith(arg_lower): tag = "[FREE]" if m.is_free else "[PAID]" - ctx = f"{m.context_window // 1000}k" if m.context_window >= 1000 else str(m.context_window) + ctx = ( + f"{m.context_window // 1000}k" + if m.context_window >= 1000 + else str(m.context_window) + ) yield Completion( m.id, start_position=-len(arg), diff --git a/agentcli/ui/tui_app.py b/agentcli/ui/tui_app.py index 8466133..46730c5 100644 --- a/agentcli/ui/tui_app.py +++ b/agentcli/ui/tui_app.py @@ -330,7 +330,11 @@ async def _handle_slash_command(self, text: str, timestamp: str, event: KeyPress budget_tier=self.config.routing.budget_tier, ) self.state.active_model = "auto" - self.add_message("system", "Switched to auto model routing [FREE/PAID auto-selection].", timestamp) + self.add_message( + "system", + "Switched to auto model routing [FREE/PAID auto-selection].", + timestamp, + ) else: if self.session: self.session.forced_model = target_model @@ -338,7 +342,9 @@ async def _handle_slash_command(self, text: str, timestamp: str, event: KeyPress self.state.active_model = target_model is_free = target_model.endswith(":free") tag = "[FREE]" if is_free else "[PAID]" - self.add_message("system", f"Forced model set to: {target_model} {tag}", timestamp) + self.add_message( + "system", f"Forced model set to: {target_model} {tag}", timestamp + ) return if cmd in {"/tokens", "/cost"}: @@ -391,11 +397,15 @@ async def _handle_slash_command(self, text: str, timestamp: str, event: KeyPress elif sub in ("list", "history"): cps = self.session.checkpoint_manager.list_checkpoints() if not cps: - self.add_message("system", "No checkpoints recorded in this session.", timestamp) + self.add_message( + "system", "No checkpoints recorded in this session.", timestamp + ) else: lines = ["Available Checkpoints:"] for cp in cps: - lines.append(f" [{cp['id']}] {cp['description']} ({cp['file_count']} files)") + lines.append( + f" [{cp['id']}] {cp['description']} ({cp['file_count']} files)" + ) self.add_message("system", "\n".join(lines), timestamp) else: res = self.session.checkpoint_manager.rollback() @@ -406,9 +416,78 @@ async def _handle_slash_command(self, text: str, timestamp: str, event: KeyPress msg += "\n" + "\n".join(f" - {f}" for f in reverted) self.add_message("system", msg, timestamp) else: - self.add_message("system", f"Rollback failed: {res.get('error')}", timestamp) + self.add_message( + "system", f"Rollback failed: {res.get('error')}", timestamp + ) + else: + self.add_message( + "system", "No active session checkpoint manager available.", timestamp + ) + return + + if cmd in {"/tasks", "/task", "/bg"}: + if self.session and hasattr(self.session, "task_manager"): + parts = text.split(maxsplit=2) + sub = parts[1].lower() if len(parts) > 1 else "list" + target_id = parts[2].strip() if len(parts) > 2 else "" + + if sub in ("list", "ls") or (len(parts) == 1): + tasks = self.session.task_manager.list_tasks() + if not tasks: + self.add_message( + "system", "No background tasks currently active.", timestamp + ) + else: + lines = [f"Background Tasks ({len(tasks)} active):"] + for t in tasks: + lines.append( + f" [{t['id']}] {t['command']} ({t['status']}, {t['uptime_seconds']}s, {t['total_lines']} lines)" + ) + self.add_message("system", "\n".join(lines), timestamp) + elif sub == "status": + if not target_id: + self.add_message("system", "Usage: /tasks status ", timestamp) + else: + status_info = self.session.task_manager.get_status(target_id) + if "error" in status_info: + self.add_message("system", f"Error: {status_info['error']}", timestamp) + else: + lines = [f"Task [{target_id}] Status:"] + for k, v in status_info.items(): + lines.append(f" {k}: {v}") + self.add_message("system", "\n".join(lines), timestamp) + elif sub in ("logs", "log"): + if not target_id: + self.add_message("system", "Usage: /tasks logs ", timestamp) + else: + log_info = self.session.task_manager.get_logs(target_id, tail=20) + if "error" in log_info: + self.add_message("system", f"Error: {log_info['error']}", timestamp) + else: + lines = [f"--- Logs for [{target_id}] ---"] + lines.extend(log_info.get("lines", [])) + self.add_message("system", "\n".join(lines), timestamp) + elif sub in ("kill", "stop"): + if not target_id: + self.add_message("system", "Usage: /tasks kill ", timestamp) + else: + res = await self.session.task_manager.kill_task(target_id) + if res.get("success"): + self.add_message("system", f"Task [{target_id}] terminated.", timestamp) + else: + self.add_message( + "system", + f"Failed to terminate [{target_id}]: {res.get('error')}", + timestamp, + ) + else: + self.add_message( + "system", + "Usage: /tasks [list | status | logs | kill ]", + timestamp, + ) else: - self.add_message("system", "No active session checkpoint manager available.", timestamp) + self.add_message("system", "No active session task manager available.", timestamp) return if cmd == "/goal": diff --git a/agentcli/watcher.py b/agentcli/watcher.py index d892dd1..dc01e7b 100644 --- a/agentcli/watcher.py +++ b/agentcli/watcher.py @@ -372,11 +372,17 @@ def __init__( self.watcher_config = watcher_config or config.watcher self.root_dir = (root_dir or Path.cwd()).resolve() self.renderer = renderer or ConsoleRenderer() + effective_paths = ( + [self.root_dir] + if (self.watcher_config.paths == ["."] and root_dir is not None) + else self.watcher_config.paths + ) self.watcher = FileWatcher( - paths=self.watcher_config.paths, + paths=effective_paths, ignored_dirs=DEFAULT_IGNORED_DIRS, debounce_seconds=self.watcher_config.debounce_seconds, ) + self.worktree_manager = WorktreeManager(self.root_dir) self.cumulative_cost_usd: float = 0.0 self._is_running: bool = False diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index 6eb782d..060ab05 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -39,7 +39,9 @@ async def test_shell_cd_commands(tmp_path: Path) -> None: assert str(Path.home()) in r_home.output["cwd"] # 3. cd to non-existent directory - t_nonexist = SubAgentTask(agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cd non_existent_folder_xyz"}) + t_nonexist = SubAgentTask( + agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cd non_existent_folder_xyz"} + ) r_nonexist = await agent.run(t_nonexist) assert r_nonexist.success is False assert r_nonexist.error is not None and "no such file or directory" in r_nonexist.error @@ -75,13 +77,17 @@ async def test_shell_fallback_shims(tmp_path: Path, monkeypatch: pytest.MonkeyPa assert "nested_dir/" in r_ls.output["stdout"] # 1b. Fallback ls with subdirectory argument - t_ls_sub = SubAgentTask(agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "ls nested_dir"}) + t_ls_sub = SubAgentTask( + agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "ls nested_dir"} + ) r_ls_sub = await agent.run(t_ls_sub) assert r_ls_sub.success is True assert "inner.txt" in r_ls_sub.output["stdout"] # 2. Fallback cat valid file - t_cat = SubAgentTask(agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cat test1.txt"}) + t_cat = SubAgentTask( + agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cat test1.txt"} + ) r_cat = await agent.run(t_cat) assert r_cat.success is True assert r_cat.output["stdout"] == "content 1" @@ -93,12 +99,16 @@ async def test_shell_fallback_shims(tmp_path: Path, monkeypatch: pytest.MonkeyPa assert r_cat_noargs.error is not None and "Usage: cat" in r_cat_noargs.error # 4. Fallback cat on non-existent file - t_cat_missing = SubAgentTask(agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cat missing.txt"}) + t_cat_missing = SubAgentTask( + agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "cat missing.txt"} + ) r_cat_missing = await agent.run(t_cat_missing) assert r_cat_missing.success is False # 5. which command when tool is missing - t_which = SubAgentTask(agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "which non_existent_tool_abc"}) + t_which = SubAgentTask( + agent_type=SubAgentType.SHELL_EXECUTION, payload={"command": "which non_existent_tool_abc"} + ) r_which = await agent.run(t_which) assert r_which.success is False assert r_which.error is not None and "not found" in r_which.error @@ -171,7 +181,9 @@ async def test_workspace_agent_branches(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_session_extended_telemetry_and_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +async def test_session_extended_telemetry_and_history( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Test AgentSession extended methods, cost calculation, and history compaction.""" monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") config = Config() diff --git a/tests/test_phase31_vital_tools.py b/tests/test_phase31_vital_tools.py index 948fee2..191bc81 100644 --- a/tests/test_phase31_vital_tools.py +++ b/tests/test_phase31_vital_tools.py @@ -108,7 +108,12 @@ async def test_web_fetch_agent_content_offset_and_raw() -> None: task = SubAgentTask( agent_type=SubAgentType.WEB_FETCH, - payload={"url": "https://example.com/data.txt", "content_offset": 100, "max_bytes": 200, "raw": True}, + payload={ + "url": "https://example.com/data.txt", + "content_offset": 100, + "max_bytes": 200, + "raw": True, + }, ) with patch("httpx.AsyncClient", return_value=mock_client): @@ -148,7 +153,9 @@ async def test_web_fetch_agent_errors() -> None: with patch("httpx.AsyncClient", return_value=mock_client_404): r_404 = await agent.run( - SubAgentTask(agent_type=SubAgentType.WEB_FETCH, payload={"url": "https://example.com/missing"}) + SubAgentTask( + agent_type=SubAgentType.WEB_FETCH, payload={"url": "https://example.com/missing"} + ) ) assert r_404.success is False assert "HTTP 404" in str(r_404.error) @@ -164,7 +171,9 @@ async def test_grep_search_python_engine(tmp_path: Path) -> None: """Test GrepSearchAgent fallback Python engine with line numbers, regex, and glob filters.""" src_dir = tmp_path / "src" src_dir.mkdir() - (src_dir / "app.py").write_text("def find_token():\n return 'secret_xyz'\n", encoding="utf-8") + (src_dir / "app.py").write_text( + "def find_token():\n return 'secret_xyz'\n", encoding="utf-8" + ) (src_dir / "utils.py").write_text("def helper():\n pass\n", encoding="utf-8") (tmp_path / "config.toml").write_text('token = "secret_xyz"\n', encoding="utf-8") @@ -221,9 +230,7 @@ async def test_grep_search_ripgrep_mocked(tmp_path: Path) -> None: agent.rg_path = "rg" # Mock ripgrep stdout JSON stream - rg_output = ( - '{"type":"match","data":{"path":{"text":"src/app.py"},"lines":{"text":"import logging\\n"},"line_number":1}}\n' - ) + rg_output = '{"type":"match","data":{"path":{"text":"src/app.py"},"lines":{"text":"import logging\\n"},"line_number":1}}\n' mock_proc = AsyncMock() mock_proc.communicate.return_value = (rg_output.encode("utf-8"), b"") diff --git a/tests/test_phase32_tasks_diagnostics.py b/tests/test_phase32_tasks_diagnostics.py new file mode 100644 index 0000000..37543d4 --- /dev/null +++ b/tests/test_phase32_tasks_diagnostics.py @@ -0,0 +1,448 @@ +"""Comprehensive test suite for Phase 32: Background Tasks, Clarification Modal, and Diagnostics Feedback.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any + +import pytest +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document + +from agentcli.agent.registry import ToolRegistry +from agentcli.agent.tasks import TaskManager +from agentcli.subagents.base import SubAgentTask, SubAgentType +from agentcli.subagents.clarification import ClarificationAgent +from agentcli.subagents.diagnostics import DiagnosticsAgent, DiagnosticsParser +from agentcli.subagents.task_manager import TaskManagerAgent +from agentcli.tools_schema import get_tool_definitions +from agentcli.ui.prompt import SlashAndFileCompleter, resolve_slash_command + +# =========================================================================== +# 1. Background TaskManager Tests +# =========================================================================== + + +@pytest.mark.asyncio +async def test_task_manager_lifecycle(tmp_path: Path) -> None: + """Test TaskManager starts, streams, inspects, and terminates background tasks.""" + mgr = TaskManager(root_dir=tmp_path) + + # Launch a fast Python process that prints two lines and waits + cmd = f"\"{sys.executable}\" -c \"import time, sys; print('STARTED_TASK'); sys.stdout.flush(); time.sleep(2); print('DONE_TASK')\"" + task = await mgr.start_task(command=cmd, cwd=tmp_path) + + assert task.id.startswith("task_") + assert task.status == "running" + assert task.cwd == str(tmp_path.resolve()) + + # Wait for the reader task to capture output + logs: dict[str, Any] = {"lines": []} + for _ in range(30): + await asyncio.sleep(0.1) + logs = mgr.get_logs(task.id) + if any("STARTED_TASK" in line for line in logs.get("lines", [])): + break + assert any("STARTED_TASK" in line for line in logs.get("lines", [])) + + + + # Offset & tail + logs_offset = mgr.get_logs(task.id, tail=1, offset=0) + assert "lines" in logs_offset + + # List tasks + all_tasks = mgr.list_tasks() + assert len(all_tasks) == 1 + assert all_tasks[0]["id"] == task.id + + # Status detail + detail = mgr.get_status(task.id) + assert detail["id"] == task.id + assert detail["status"] in ("running", "completed") + + # Kill task + kill_res = await mgr.kill_task(task.id) + assert kill_res["success"] is True + assert kill_res["status"] == "killed" + assert task.status == "killed" + + # Kill already killed + kill_again = await mgr.kill_task(task.id) + assert kill_again["success"] is True + + # Cleanup all + await mgr.cleanup_all() + + +@pytest.mark.asyncio +async def test_task_manager_stdin_and_failed_start(tmp_path: Path) -> None: + """Test sending stdin to a task and handling start failures.""" + mgr = TaskManager(root_dir=tmp_path) + + # Test stdin + cmd = f'"{sys.executable}" -c "import sys; line = sys.stdin.readline(); print(\'ECHO:\' + line.strip()); sys.stdout.flush()"' + task = await mgr.start_task(command=cmd, cwd=tmp_path) + await asyncio.sleep(0.2) + + send_res = await mgr.send_input(task.id, "hello_agentcli") + assert send_res["success"] is True + + # Allow task to process and finish + logs: dict[str, Any] = {"lines": []} + for _ in range(30): + await asyncio.sleep(0.1) + logs = mgr.get_logs(task.id) + if any("ECHO:hello_agentcli" in line for line in logs.get("lines", [])): + break + assert any("ECHO:hello_agentcli" in line for line in logs.get("lines", [])) + + + # Test unknown task errors + assert "error" in mgr.get_status("invalid_id") + assert "error" in mgr.get_logs("invalid_id") + assert (await mgr.send_input("invalid_id", "test"))["success"] is False + assert (await mgr.kill_task("invalid_id"))["success"] is False + + +@pytest.mark.asyncio +async def test_task_manager_agent_actions(tmp_path: Path) -> None: + """Test TaskManagerAgent subagent interface across all supported actions.""" + mgr = TaskManager(root_dir=tmp_path) + agent = TaskManagerAgent(task_manager=mgr) + + # 1. Run action + cmd = f'"{sys.executable}" -c "print(\'AGENT_ACTION_OK\')"' + t_run = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "run", "command": cmd, "cwd": str(tmp_path)}, + ) + r_run = await agent.run(t_run) + assert r_run.success is True + task_id = r_run.output["id"] + + # 2. List action + t_list = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "list"}, + ) + r_list = await agent.run(t_list) + assert r_list.success is True + assert r_list.output["total"] >= 1 + + # 3. Status action + t_status = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "status", "task_id": task_id}, + ) + r_status = await agent.run(t_status) + assert r_status.success is True + assert r_status.output["id"] == task_id + + # 4. Logs action + t_logs = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "logs", "task_id": task_id, "tail": 10}, + ) + r_logs = None + for _ in range(30): + await asyncio.sleep(0.1) + r_logs = await agent.run(t_logs) + if r_logs.success and any("AGENT_ACTION_OK" in line for line in r_logs.output.get("lines", [])): + break + assert r_logs is not None and r_logs.success is True + assert any("AGENT_ACTION_OK" in line for line in r_logs.output["lines"]) + + + # 5. Send input action + t_input = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "send_input", "task_id": task_id, "input": "test"}, + ) + r_input = await agent.run(t_input) + assert isinstance(r_input.output, dict) + + # 6. Kill action + t_kill = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "kill", "task_id": task_id}, + ) + r_kill = await agent.run(t_kill) + assert r_kill.success is True + + # 7. Missing parameter errors + assert ( + await agent.run( + SubAgentTask(agent_type=SubAgentType.TASK_MANAGER, payload={"action": "run"}) + ) + ).success is False + assert ( + await agent.run( + SubAgentTask(agent_type=SubAgentType.TASK_MANAGER, payload={"action": "status"}) + ) + ).success is False + assert ( + await agent.run( + SubAgentTask(agent_type=SubAgentType.TASK_MANAGER, payload={"action": "logs"}) + ) + ).success is False + assert ( + await agent.run( + SubAgentTask(agent_type=SubAgentType.TASK_MANAGER, payload={"action": "send_input"}) + ) + ).success is False + assert ( + await agent.run( + SubAgentTask(agent_type=SubAgentType.TASK_MANAGER, payload={"action": "kill"}) + ) + ).success is False + + # 8. Unknown action error + t_unknown = SubAgentTask( + agent_type=SubAgentType.TASK_MANAGER, + payload={"action": "unknown_action"}, + ) + r_unknown = await agent.run(t_unknown) + assert r_unknown.success is False + assert "Unknown task manager action" in str(r_unknown.error) + + +# =========================================================================== +# 2. ClarificationAgent Tests +# =========================================================================== + + +@pytest.mark.asyncio +async def test_clarification_agent_interactive_and_headless() -> None: + """Test ClarificationAgent interactive callback and non-interactive fallbacks.""" + # 1. Non-interactive fallback with recommendations + agent = ClarificationAgent() + task = SubAgentTask( + agent_type=SubAgentType.ASK_QUESTION, + payload={ + "question": "Which database would you like to use?", + "options": ["(Recommended) SQLite", "PostgreSQL", "MySQL"], + "is_multi_select": False, + }, + ) + res = await agent.run(task) + assert res.success is True + assert res.output["interactive"] is False + assert res.output["selected"] == "(Recommended) SQLite" + + # 2. Non-interactive fallback with NO options + task_no_opts = SubAgentTask( + agent_type=SubAgentType.ASK_QUESTION, + payload={"question": "What is the project name?"}, + ) + res_no_opts = await agent.run(task_no_opts) + assert res_no_opts.success is True + assert "proceeding with standard defaults" in res_no_opts.output["answer"] + + # 3. Interactive async handler + async def mock_async_handler(q: str, opts: list[str] | None, multi: bool) -> str: + return "PostgreSQL" + + agent.set_handler(mock_async_handler) + res_interactive = await agent.run(task) + assert res_interactive.success is True + assert res_interactive.output["interactive"] is True + assert res_interactive.output["answer"] == "PostgreSQL" + + # 4. Synchronous handler + def mock_sync_handler(q: str, opts: list[str] | None, multi: bool) -> str: + return "MySQL" + + agent.set_handler(mock_sync_handler) + res_sync = await agent.run(task) + assert res_sync.success is True + assert res_sync.output["answer"] == "MySQL" + + # 5. Handler exception gracefully handled + def failing_handler(q: str, opts: list[str] | None, multi: bool) -> None: + raise RuntimeError("UI closed") + + agent.set_handler(failing_handler) + res_fallback = await agent.run(task) + assert res_fallback.success is True + assert res_fallback.output["interactive"] is False + + # 6. Payload with questions array + t_array = SubAgentTask( + agent_type=SubAgentType.ASK_QUESTION, + payload={ + "questions": [ + { + "question": "Select target environments", + "options": ["staging", "production"], + "is_multi_select": True, + } + ] + }, + ) + agent.handler = None # Reset to non-interactive + res_array = await agent.run(t_array) + assert res_array.success is True + assert res_array.output["selected"] == ["staging"] + + # 7. Missing question error + t_err = SubAgentTask( + agent_type=SubAgentType.ASK_QUESTION, + payload={}, + ) + res_err = await agent.run(t_err) + assert res_err.success is False + assert "Missing required 'question'" in str(res_err.error) + + +# =========================================================================== +# 3. DiagnosticsParser & DiagnosticsAgent Tests +# =========================================================================== + + +def test_diagnostics_parser_formats() -> None: + """Test parser across Ruff, Mypy, TypeScript, ESLint, Rust/Cargo, and Generic formats.""" + # Empty string + assert DiagnosticsParser.parse("") == [] + + # Ruff format + ruff_text = "agentcli/app.py:14:5: E501 line too long (92 > 88 characters)\nagentcli/app.py:20:1: F401 'sys' imported but unused\n" + spans_ruff = DiagnosticsParser.parse(ruff_text, framework="ruff") + assert len(spans_ruff) == 2 + assert spans_ruff[0].file == "agentcli/app.py" + assert spans_ruff[0].line == 14 + assert spans_ruff[0].column == 5 + assert spans_ruff[0].code == "E501" + + # Mypy format + mypy_text = "agentcli/session.py:42: error: Incompatible types in assignment [assignment]\nagentcli/session.py:55: note: See docs for details\n" + spans_mypy = DiagnosticsParser.parse(mypy_text, framework="mypy") + assert len(spans_mypy) == 2 + assert spans_mypy[0].severity == "error" + assert spans_mypy[0].code == "assignment" + assert spans_mypy[1].severity == "note" + + # TypeScript format + tsc_text = "src/index.ts(15,22): error TS2322: Type 'string' is not assignable to type 'number'.\nsrc/util.ts:25:10 - error TS2304: Cannot find name 'foo'.\n" + spans_tsc = DiagnosticsParser.parse(tsc_text, framework="tsc") + assert len(spans_tsc) == 2 + assert spans_tsc[0].line == 15 + assert spans_tsc[0].code == "TS2322" + assert spans_tsc[1].line == 25 + + # ESLint format + eslint_text = ( + "src/app.js: line 10, col 3, Error - 'val' is defined but never used (no-unused-vars)\n" + ) + spans_eslint = DiagnosticsParser.parse(eslint_text, framework="eslint") + assert len(spans_eslint) == 1 + assert spans_eslint[0].line == 10 + assert spans_eslint[0].column == 3 + assert spans_eslint[0].code == "no-unused-vars" + + # Rust/Cargo format + rust_text = "error[E0425]: cannot find value `foo` in this scope\n --> src/main.rs:12:5\n" + spans_rust = DiagnosticsParser.parse(rust_text, framework="cargo") + assert len(spans_rust) == 1 + assert spans_rust[0].file == "src/main.rs" + assert spans_rust[0].line == 12 + assert spans_rust[0].column == 5 + assert spans_rust[0].code == "E0425" + + # Generic format fallback + generic_text = "build/out.c:18:4: warning: implicit declaration of function\n" + spans_gen = DiagnosticsParser.parse(generic_text, framework="auto") + assert len(spans_gen) == 1 + assert spans_gen[0].file == "build/out.c" + assert spans_gen[0].line == 18 + assert spans_gen[0].severity == "warning" + + # Pytest format + pytest_text = ( + "FAILED tests/test_core.py::test_login - AssertionError: assert False\n" + "tests/test_core.py:42: AssertionError\n" + ) + spans_pytest = DiagnosticsParser.parse(pytest_text, framework="pytest") + assert len(spans_pytest) == 1 + assert spans_pytest[0].file == "tests/test_core.py" + assert spans_pytest[0].line == 42 + assert "test_login" in spans_pytest[0].message + + +@pytest.mark.asyncio +async def test_diagnostics_agent_execution() -> None: + """Test DiagnosticsAgent with raw output string, clean command, and failed command.""" + agent = DiagnosticsAgent() + + # 1. Parsing raw output + raw_output = ( + "agentcli/core.py:10:1: F401 'os' imported but unused\n" + "agentcli/core.py:25: error: Item 'None' of 'Optional[str]' has no attribute 'lower' [union-attr]\n" + ) + + task = SubAgentTask( + agent_type=SubAgentType.DIAGNOSTICS_CHECK, + payload={"output": raw_output, "framework": "auto"}, + ) + res = await agent.run(task) + assert res.success is True + assert res.output["total_diagnostics"] == 2 + assert res.output["errors_count"] == 2 + assert "Found 2 error(s)" in res.output["summary"] + + # 2. Clean command execution (exit 0, no errors) + cmd_clean = f'"{sys.executable}" -c "print(\'All good\')"' + task_clean = SubAgentTask( + agent_type=SubAgentType.DIAGNOSTICS_CHECK, + payload={"command": cmd_clean}, + ) + res_clean = await agent.run(task_clean) + assert res_clean.success is True + assert "0 errors and 0 warnings" in res_clean.output["summary"] + + +# =========================================================================== +# 4. Registry, Schema, and UI Slash Command Tests +# =========================================================================== + + +@pytest.mark.asyncio +async def test_tool_registry_and_definitions() -> None: + """Verify manage_task, ask_question, and diagnostics_check in registry and schema.""" + registry = ToolRegistry() + registered = registry.registered_types() + assert SubAgentType.TASK_MANAGER.value in registered + assert SubAgentType.ASK_QUESTION.value in registered + assert SubAgentType.DIAGNOSTICS_CHECK.value in registered + + defs = get_tool_definitions() + names = {d["function"]["name"] for d in defs} + assert "manage_task" in names + assert "ask_question" in names + assert "diagnostics_check" in names + + # Execute via registry + r_task = await registry.execute("task_manager", {"action": "list"}) + assert r_task.success is True + + r_ask = await registry.execute("ask_question", {"question": "Ready?"}) + assert r_ask.success is True + + r_diag = await registry.execute("diagnostics_check", {"output": ""}) + assert r_diag.success is True + + +def test_ui_slash_tasks_resolution() -> None: + """Test /tasks slash command resolution and autocompletion.""" + assert resolve_slash_command("/task") == "/tasks" + assert resolve_slash_command("/bg") == "/tasks" + assert resolve_slash_command("/tasks list") == "/tasks list" + assert resolve_slash_command("\\tasks") == "/tasks" + + completer = SlashAndFileCompleter() + doc = Document("/tas") + completions = [c.text for c in completer.get_completions(doc, CompleteEvent())] + assert "/tasks" in completions + diff --git a/tests/test_registry.py b/tests/test_registry.py index d581c80..3dd6519 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -167,4 +167,3 @@ async def test_registry_refresh_from_openrouter() -> None: rec_gpt = registry.get("openai/gpt-4o") assert rec_gpt is not None assert rec_gpt.is_free is False - diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 43d8143..7d5f8ed 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -421,7 +421,9 @@ async def test_tui_submit_input_branches(monkeypatch: pytest.MonkeyPatch) -> Non submit_handler(mock_event) await asyncio.sleep(0.01) # Applied completion and executed /model - assert any("Available Models Catalog" in m[1] or "Current model" in m[1] for m in tui.state.messages) + assert any( + "Available Models Catalog" in m[1] or "Current model" in m[1] for m in tui.state.messages + ) tui.input_buffer.complete_state = None # 4. Busy processing warning @@ -483,7 +485,9 @@ async def test_tui_more_slash_commands() -> None: # /model without args await tui._handle_slash_command("/model", "12:00:02", mock_event) - assert any("Available Models Catalog" in m[1] or "Current model" in m[1] for m in tui.state.messages) + assert any( + "Available Models Catalog" in m[1] or "Current model" in m[1] for m in tui.state.messages + ) # /model auto await tui._handle_slash_command("/model auto", "12:00:03", mock_event) @@ -505,8 +509,14 @@ async def test_tui_more_slash_commands() -> None: # /undo commands mock_session.checkpoint_manager = MagicMock() mock_session.checkpoint_manager.get_diff.return_value = "--- a/test.py\n+++ b/test.py" - mock_session.checkpoint_manager.list_checkpoints.return_value = [{"id": "cp1", "description": "test", "file_count": 1}] - mock_session.checkpoint_manager.rollback.return_value = {"success": True, "checkpoint_id": "cp1", "reverted_files": ["test.py"]} + mock_session.checkpoint_manager.list_checkpoints.return_value = [ + {"id": "cp1", "description": "test", "file_count": 1} + ] + mock_session.checkpoint_manager.rollback.return_value = { + "success": True, + "checkpoint_id": "cp1", + "reverted_files": ["test.py"], + } await tui._handle_slash_command("/undo diff", "12:00:07", mock_event) assert tui.state.is_diff_modal_open is True @@ -517,6 +527,40 @@ async def test_tui_more_slash_commands() -> None: await tui._handle_slash_command("/undo", "12:00:09", mock_event) assert any("Rollback successful" in m[1] for m in tui.state.messages) + # /tasks commands + mock_session.task_manager = MagicMock() + mock_session.task_manager.list_tasks.return_value = [ + { + "id": "task_1", + "command": "pytest", + "status": "running", + "uptime_seconds": 1.2, + "total_lines": 5, + } + ] + mock_session.task_manager.get_status.return_value = { + "id": "task_1", + "status": "running", + "command": "pytest", + } + mock_session.task_manager.get_logs.return_value = { + "status": "running", + "lines": ["line 1", "line 2"], + } + mock_session.task_manager.kill_task = AsyncMock(return_value={"success": True}) + + await tui._handle_slash_command("/tasks", "12:00:10", mock_event) + assert any("Background Tasks" in m[1] for m in tui.state.messages) + + await tui._handle_slash_command("/tasks status task_1", "12:00:11", mock_event) + assert any("Task [task_1] Status:" in m[1] for m in tui.state.messages) + + await tui._handle_slash_command("/tasks logs task_1", "12:00:12", mock_event) + assert any("--- Logs for [task_1] ---" in m[1] for m in tui.state.messages) + + await tui._handle_slash_command("/tasks kill task_1", "12:00:13", mock_event) + assert any("Task [task_1] terminated." in m[1] for m in tui.state.messages) + @pytest.mark.asyncio async def test_run_tui_entrypoint(monkeypatch) -> None: diff --git a/tests/test_watcher.py b/tests/test_watcher.py index 1d718c5..5dcc91e 100644 --- a/tests/test_watcher.py +++ b/tests/test_watcher.py @@ -126,15 +126,16 @@ async def trigger_changes(): f1.write_text("x = 3", encoding="utf-8") os.utime(f1, (time.time() + 3.0, time.time() + 3.0)) - task = asyncio.create_task(trigger_changes()) + batches: list[set[Path]] = [] - batches = [] - async for change_batch in watcher.watch(poll_interval=0.02): - batches.append(change_batch) - if len(batches) >= 1: - watcher.stop() + async def run_watcher(): + async for change_batch in watcher.watch(poll_interval=0.02): + batches.append(change_batch) + if len(batches) >= 1: + watcher.stop() + break - await task + await asyncio.wait_for(asyncio.gather(run_watcher(), trigger_changes()), timeout=3.0) assert len(batches) >= 1 assert f1.resolve() in batches[0] @@ -153,11 +154,11 @@ async def test_worktree_manager_git_lifecycle(tmp_path: Path): assert mgr.is_git_repo() is False # Mock create_worktree and remove_worktree - mock_proc = AsyncMock() - mock_proc.communicate.return_value = (b"", b"") + mock_proc = MagicMock() + mock_proc.communicate = AsyncMock(return_value=(b"", b"")) mock_proc.returncode = 0 - with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)): wt_dir, branch = await mgr.create_worktree(branch_prefix="test-repair") assert "test-repair" in branch assert wt_dir.parent == (tmp_path / ".agentcli_worktrees") @@ -176,21 +177,35 @@ async def test_worktree_manager_git_lifecycle(tmp_path: Path): async def test_continuous_tdd_runner_test_execution(tmp_path: Path): """Test ContinuousTDDRunner test command execution and failure summaries.""" config = Config() - config.watcher.test_command = "echo passed" - runner = ContinuousTDDRunner(config=config, root_dir=tmp_path) # Mock passing test - res = await runner.run_tests() - assert res.passed is True - assert res.return_code == 0 + mock_proc = MagicMock() + mock_proc.communicate = AsyncMock(return_value=(b"passed\n", b"")) + mock_proc.returncode = 0 + + with patch("asyncio.create_subprocess_shell", AsyncMock(return_value=mock_proc)): + res = await runner.run_tests() + assert res.passed is True + assert res.return_code == 0 # Mock failing test - runner.watcher_config.test_command = "python -c \"import sys; sys.stderr.write('FAILED tests/test_app.py::test_fail - AssertionError'); sys.exit(1)\"" - fail_res = await runner.run_tests() - assert fail_res.passed is False - assert fail_res.return_code == 1 - assert "FAILED" in fail_res.failure_summary or "AssertionError" in fail_res.failure_summary + mock_fail = MagicMock() + mock_fail.communicate = AsyncMock( + return_value=( + b"", + b"FAILED tests/test_app.py::test_fail - AssertionError\n", + ) + ) + mock_fail.returncode = 1 + + with patch("asyncio.create_subprocess_shell", AsyncMock(return_value=mock_fail)): + fail_res = await runner.run_tests() + assert fail_res.passed is False + assert fail_res.return_code == 1 + assert "FAILED" in fail_res.failure_summary or "AssertionError" in fail_res.failure_summary + + @pytest.mark.asyncio @@ -354,7 +369,7 @@ def test_cli_build_parser_watch_subcommand(): @pytest.mark.asyncio -async def test_run_watch_entrypoint(monkeypatch): +async def test_run_watch_entrypoint(tmp_path: Path): """Test run_watch entrypoint sets up runner and executes.""" args = argparse.Namespace( command="watch", @@ -366,13 +381,14 @@ async def test_run_watch_entrypoint(monkeypatch): budget="medium", model=None, max_iterations=4, - paths=["."], + paths=[str(tmp_path)], no_initial=True, plain=True, no_color=True, ) config = Config() + with patch.object(ContinuousTDDRunner, "run", new_callable=AsyncMock) as mock_run: mock_run.return_value = ExitCode.SUCCESS res = await run_watch(args, config) @@ -480,12 +496,12 @@ async def test_worktree_manager_create_error(tmp_path: Path): """Test WorktreeManager raises RuntimeError when git worktree creation fails.""" mgr = WorktreeManager(root_dir=tmp_path) - mock_proc = AsyncMock() - mock_proc.communicate.return_value = (b"", b"fatal: git worktree add failed") + mock_proc = MagicMock() + mock_proc.communicate = AsyncMock(return_value=(b"", b"fatal: git worktree add failed")) mock_proc.returncode = 1 with ( - patch("asyncio.create_subprocess_exec", return_value=mock_proc), + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)), pytest.raises(RuntimeError, match="Failed to create git worktree"), ): await mgr.create_worktree()