diff --git a/README.md b/README.md index c49fab361..bf0208862 100644 --- a/README.md +++ b/README.md @@ -466,8 +466,11 @@ Agents can access these tools based on their configuration: ## File Locations -### JSON Agents Directory -- **All platforms**: `~/.code_puppy/agents/` +### JSON Agents Directories +- **User-level (global)**: `~/.code_puppy/agents/` +- **Project-level (shared via version control)**: `.code_puppy/agents/` + +Project-level agents in `.code_puppy/agents/` override user-level agents on name collision. This directory should be committed to version control for team sharing. ### Python Agents Directory - **Built-in**: `code_puppy/agents/` (in package) @@ -501,8 +504,9 @@ Agents can access these tools based on their configuration: ### Agent Discovery The system automatically discovers agents by: 1. **Python Agents**: Scanning `code_puppy/agents/` for classes inheriting from `BaseAgent` -2. **JSON Agents**: Scanning user's agents directory for `*-agent.json` files -3. Instantiating and registering discovered agents +2. **JSON Agents (user-level)**: Scanning `~/.code_puppy/agents/` for `*.json` files +3. **JSON Agents (project-level)**: Scanning `.code_puppy/agents/` for `*.json` files (overrides user-level on name collision) +4. Instantiating and registering discovered agents ### JSONAgent Implementation JSON agents are powered by the `JSONAgent` class (`code_puppy/agents/json_agent.py`): @@ -647,11 +651,13 @@ The agent system supports future expansion: - **Testing**: Comprehensive test suite in `tests/test_json_agents.py` ### JSON Agent Loading Process -1. System scans `~/.code_puppy/agents/` for `*-agent.json` files -2. `JSONAgent` class loads and validates each JSON configuration -3. Agents are registered in unified agent registry -4. Users can switch to JSON agents via `/agent ` command -5. Tool access and system prompts work identically to Python agents +1. System scans `~/.code_puppy/agents/` for `*.json` agent files (user-level) +2. System scans `.code_puppy/agents/` for `*.json` agent files (project-level) +3. Project-level agents override user-level agents on name collision +4. `JSONAgent` class loads and validates each JSON configuration +5. Agents are registered in unified agent registry +6. Users can switch to JSON agents via `/agent ` command +7. Tool access and system prompts work identically to Python agents ### Error Handling - Invalid JSON syntax: Clear error messages with line numbers diff --git a/code_puppy/agents/__init__.py b/code_puppy/agents/__init__.py index 70c071daf..0c36ef8ba 100644 --- a/code_puppy/agents/__init__.py +++ b/code_puppy/agents/__init__.py @@ -8,7 +8,10 @@ clone_agent, delete_clone_agent, get_agent_descriptions, + get_agent_shadowed_path, + get_agent_source_path, get_available_agents, + get_available_agents_with_descriptions, get_current_agent, is_clone_agent_name, load_agent, @@ -20,12 +23,15 @@ __all__ = [ "clone_agent", "delete_clone_agent", + "get_agent_descriptions", + "get_agent_shadowed_path", + "get_agent_source_path", "get_available_agents", + "get_available_agents_with_descriptions", "get_current_agent", "is_clone_agent_name", "set_current_agent", "load_agent", - "get_agent_descriptions", "refresh_agents", "subagent_stream_handler", ] diff --git a/code_puppy/agents/agent_creator_agent.py b/code_puppy/agents/agent_creator_agent.py index 1c4ca6fc7..86a954d08 100644 --- a/code_puppy/agents/agent_creator_agent.py +++ b/code_puppy/agents/agent_creator_agent.py @@ -1,10 +1,8 @@ """Agent Creator - helps users create new JSON agents.""" -import json -import os from typing import Dict, List, Optional -from code_puppy.config import get_user_agents_directory +from code_puppy.config import get_user_agents_directory, get_project_agents_directory from code_puppy.model_factory import ModelFactory from code_puppy.tools import get_available_tool_names @@ -29,6 +27,7 @@ def description(self) -> str: def get_system_prompt(self) -> str: available_tools = get_available_tool_names() agents_dir = get_user_agents_directory() + project_agents_dir = get_project_agents_directory() # Also get Universal Constructor tools (custom tools created by users) uc_tools_info = [] @@ -65,6 +64,41 @@ def get_system_prompt(self) -> str: available_models_str = "\n".join(model_descriptions) + # Build the file-creation instructions for the system prompt. + # When a project agents directory exists, ask the user where to save. + # When only the user directory is available, skip the question entirely. + if project_agents_dir: + _file_creation_instructions = f"""**File Creation:** +- BEFORE creating the file, you MUST ask where to save it using `ask_user_question` with this EXACT format: + ```json + {{{{ + "questions": [ + {{{{ + "question": "Where should this agent be saved?", + "header": "Location", + "multi_select": false, + "options": [ + {{{{ + "label": "User directory", + "description": "{agents_dir} (available in all projects)" + }}}}, + {{{{ + "label": "Project directory", + "description": "{project_agents_dir} (version controlled)" + }}}} + ] + }}}} + ] + }}}} + ``` +- THEN use `edit_file` to create the JSON file at the chosen location: + - If user chose "User directory": save to `{agents_dir}/agent-name.json` + - If user chose "Project directory": save to `{project_agents_dir}/agent-name.json`""" + else: + _file_creation_instructions = f"""**File Creation:** +- Do NOT ask the user where to save — save directly to the user agents directory. +- Use `edit_file` to create the JSON file at: `{agents_dir}/agent-name.json`""" + return f"""You are the Agent Creator! 🏗️ Your mission is to help users create awesome JSON agent files through an interactive process. You specialize in: @@ -399,10 +433,9 @@ def get_system_prompt(self) -> str: - ✅ If changes needed: gather feedback and regenerate - ✅ NEVER ask permission to create the file after confirmation is given -**File Creation:** -- ALWAYS use the `edit_file` tool to create the JSON file -- Save to the agents directory: `{agents_dir}` -- Always notify user of successful creation with file path +{_file_creation_instructions} +- IMPORTANT: Never use `~` in file paths. Always use the fully expanded paths shown above +- Always notify user of successful creation with full file path - Explain how to use the new agent with `/agent agent-name` ## Tool Suggestion Examples: @@ -581,50 +614,6 @@ def validate_agent_json(self, agent_config: Dict) -> List[str]: return errors - def get_agent_file_path(self, agent_name: str) -> str: - """Get the full file path for an agent JSON file. - - Args: - agent_name: The agent name - - Returns: - Full path to the agent JSON file - """ - agents_dir = get_user_agents_directory() - return os.path.join(agents_dir, f"{agent_name}.json") - - def create_agent_json(self, agent_config: Dict) -> tuple[bool, str]: - """Create a JSON agent file. - - Args: - agent_config: The agent configuration dictionary - - Returns: - Tuple of (success, message) - """ - # Validate the configuration - errors = self.validate_agent_json(agent_config) - if errors: - return False, "Validation errors:\n" + "\n".join( - f"- {error}" for error in errors - ) - - # Get file path - agent_name = agent_config["name"] - file_path = self.get_agent_file_path(agent_name) - - # Check if file already exists - if os.path.exists(file_path): - return False, f"Agent '{agent_name}' already exists at {file_path}" - - # Create the JSON file - try: - with open(file_path, "w", encoding="utf-8") as f: - json.dump(agent_config, f, indent=2, ensure_ascii=False) - return True, f"Successfully created agent '{agent_name}' at {file_path}" - except Exception as e: - return False, f"Failed to create agent file: {e}" - def get_user_prompt(self) -> Optional[str]: """Get the initial user prompt.""" return "Hi! I'm the Agent Creator 🏗️ Let's build an awesome agent together!" diff --git a/code_puppy/agents/agent_manager.py b/code_puppy/agents/agent_manager.py index 118ee2709..45494fc76 100644 --- a/code_puppy/agents/agent_manager.py +++ b/code_puppy/agents/agent_manager.py @@ -8,7 +8,7 @@ import threading import uuid from pathlib import Path -from typing import Dict, List, Optional, Type, Union +from typing import Dict, List, Optional, Tuple, Type, Union from pydantic_ai.messages import ModelMessage @@ -278,7 +278,7 @@ def _discover_agents(message_group_id: Optional[str] = None): ) continue - # 2. Discover JSON agents in user directory + # 2. Discover JSON agents in user directory and project directory try: json_agents = discover_json_agents() @@ -477,6 +477,43 @@ def load_agent(agent_name: str) -> BaseAgent: return agent_ref() +def get_agent_source_path(agent_name: str) -> Optional[str]: + """Get the file path for a JSON agent, or None for Python agents. + + Args: + agent_name: The agent name to look up. + + Returns: + File path string if agent is JSON-based, None otherwise. + """ + if agent_name not in _AGENT_REGISTRY: + message_group_id = str(uuid.uuid4()) + _discover_agents(message_group_id=message_group_id) + ref = _AGENT_REGISTRY.get(agent_name) + return ref if isinstance(ref, str) else None + + +def get_agent_shadowed_path(agent_name: str) -> Optional[str]: + """Get the user-level path that a project agent is shadowing, if any. + + Returns the path of the user-level JSON agent that was overridden by a + project-level agent with the same name, or None if there is no conflict. + + Args: + agent_name: The agent name to look up. + + Returns: + File path of the shadowed user agent, or None. + """ + from code_puppy.agents.json_agent import discover_json_agents_with_sources + + sources = discover_json_agents_with_sources() + info = sources.get(agent_name) + if info is None: + return None + return info.get("shadowed_path") + + def get_agent_descriptions() -> Dict[str, str]: """Get descriptions for all available agents. @@ -522,6 +559,46 @@ def get_agent_descriptions() -> Dict[str, str]: return descriptions +def get_available_agents_with_descriptions() -> Dict[str, Tuple[str, str]]: + """Get agents with display names and descriptions in a single discovery pass. + + Prefer this over calling get_available_agents() + get_agent_descriptions() + separately, which each trigger a full _discover_agents() scan. + + Returns: + Dict mapping agent name to (display_name, description). + """ + from ..config import ( + PACK_AGENT_NAMES, + UC_AGENT_NAMES, + get_pack_agents_enabled, + get_universal_constructor_enabled, + ) + + message_group_id = str(uuid.uuid4()) + _discover_agents(message_group_id=message_group_id) + + pack_agents_enabled = get_pack_agents_enabled() + uc_enabled = get_universal_constructor_enabled() + + result: Dict[str, Tuple[str, str]] = {} + for name, agent_ref in _AGENT_REGISTRY.items(): + if not pack_agents_enabled and name in PACK_AGENT_NAMES: + continue + if not uc_enabled and name in UC_AGENT_NAMES: + continue + try: + if isinstance(agent_ref, str): + agent_instance = JSONAgent(agent_ref) + else: + agent_instance = agent_ref() + result[name] = (agent_instance.display_name, agent_instance.description) + except Exception: + result[name] = (name.title(), "No description available") + + return result + + def refresh_agents(): """Refresh the agent discovery to pick up newly created agents. @@ -593,11 +670,68 @@ def _next_clone_index( next_index += 1 -def clone_agent(agent_name: str) -> Optional[str]: - """Clone an agent definition into the user agents directory. +_PROJECT_DIR_LABEL = "Project directory" +_USER_DIR_LABEL = "User directory" + + +def _ask_clone_location() -> Optional[Path]: + """Ask user where to save the cloned agent. + + Returns: + Directory Path, or None if cancelled. + """ + from code_puppy.tools.ask_user_question.handler import ask_user_question + from ..config import get_user_agents_directory, get_project_agents_directory + + project_dir = get_project_agents_directory() + + # Skip the prompt entirely when there is no project directory — no choice to make. + if not project_dir: + return Path(get_user_agents_directory()) + + options = [ + { + "label": _USER_DIR_LABEL, + "description": "~/.code_puppy/agents/ (available in all projects)", + }, + { + "label": _PROJECT_DIR_LABEL, + "description": ".code_puppy/agents/ (version controlled)", + }, + ] + + result = ask_user_question( + [ + { + "question": "Where should the cloned agent be saved?", + "header": "Location", + "multi_select": False, + "options": options, + } + ] + ) + + if result.cancelled or not result.answers: + return None + + selected = result.answers[0].selected_options + if not selected: + return None + choice = selected[0] + if choice == _PROJECT_DIR_LABEL and project_dir: + return Path(project_dir) + return Path(get_user_agents_directory()) + + +def clone_agent(agent_name: str, target_dir: Optional[Path] = None) -> Optional[str]: + """Clone an agent definition. Args: agent_name: Source agent name to clone. + target_dir: Directory to save the clone. If None, asks the user + interactively (only works outside of a TUI context). + When called from the agent menu, pass the source agent's + parent directory to avoid nested TUI conflicts. Returns: The cloned agent name, or None if cloning failed. @@ -611,9 +745,17 @@ def clone_agent(agent_name: str) -> Optional[str]: emit_warning(f"Agent '{agent_name}' not found for cloning.") return None - from ..config import get_agent_pinned_model, get_user_agents_directory - - agents_dir = Path(get_user_agents_directory()) + from ..config import get_agent_pinned_model + + # Determine target directory + if target_dir is not None: + agents_dir = target_dir + else: + # Interactive prompt - only works outside of a TUI context + agents_dir = _ask_clone_location() + if agents_dir is None: + emit_warning("Clone cancelled - no location selected.") + return None base_name = _strip_clone_suffix(agent_name) existing_names = set(_AGENT_REGISTRY.keys()) clone_index = _next_clone_index(base_name, existing_names, agents_dir) @@ -676,7 +818,7 @@ def clone_agent(agent_name: str) -> Optional[str]: try: with open(clone_path, "w", encoding="utf-8") as f: json.dump(clone_config, f, indent=2, ensure_ascii=False) - emit_success(f"Cloned '{agent_name}' to '{clone_name}'.") + emit_success(f"Cloned '{agent_name}' to '{clone_name}' at {clone_path}") return clone_name except Exception as exc: emit_warning(f"Failed to write clone file '{clone_path}': {exc}") @@ -684,52 +826,62 @@ def clone_agent(agent_name: str) -> Optional[str]: def delete_clone_agent(agent_name: str) -> bool: - """Delete a cloned JSON agent definition. + """Delete a JSON agent definition. + + Only agents whose files live inside the known user or project agents + directories are eligible for deletion. This guard is enforced here in the + manager (public API) — not only at the UI layer — so that callers outside + the TUI cannot delete arbitrary files via the registry. Args: - agent_name: Clone agent name to delete. + agent_name: Agent name to delete. Returns: - True if the clone was deleted, False otherwise. + True if the agent was deleted, False otherwise. """ message_group_id = str(uuid.uuid4()) _discover_agents(message_group_id=message_group_id) - if not is_clone_agent_name(agent_name): - emit_warning(f"Agent '{agent_name}' is not a clone.") - return False - if get_current_agent_name() == agent_name: emit_warning("Cannot delete the active agent. Switch agents first.") return False agent_ref = _AGENT_REGISTRY.get(agent_name) if agent_ref is None: - emit_warning(f"Clone '{agent_name}' not found.") + emit_warning(f"Agent '{agent_name}' not found.") return False if not isinstance(agent_ref, str): - emit_warning(f"Clone '{agent_name}' is not a JSON agent.") + emit_warning(f"Agent '{agent_name}' is not a JSON agent.") return False - clone_path = Path(agent_ref) - if not clone_path.exists(): - emit_warning(f"Clone file for '{agent_name}' does not exist.") + agent_path = Path(agent_ref) + if not agent_path.exists(): + emit_warning(f"Agent file for '{agent_name}' does not exist.") return False - from ..config import get_user_agents_directory + # Directory-confinement guard: must live in manager (public API entry point), + # not only in the TUI, so external callers cannot delete arbitrary paths. + from ..config import get_user_agents_directory, get_project_agents_directory + + allowed_dirs: set[Path] = set() + if u := get_user_agents_directory(): + allowed_dirs.add(Path(u).resolve()) + if p := get_project_agents_directory(): + allowed_dirs.add(Path(p).resolve()) - agents_dir = Path(get_user_agents_directory()).resolve() - if clone_path.resolve().parent != agents_dir: - emit_warning(f"Refusing to delete non-user clone '{agent_name}'.") + if agent_path.resolve().parent not in allowed_dirs: + emit_warning( + f"Refusing to delete '{agent_name}' outside known agents directories." + ) return False try: - clone_path.unlink() - emit_success(f"Deleted clone '{agent_name}'.") + agent_path.unlink() + emit_success(f"Deleted agent '{agent_name}'.") _AGENT_REGISTRY.pop(agent_name, None) _AGENT_HISTORIES.pop(agent_name, None) return True except Exception as exc: - emit_warning(f"Failed to delete clone '{agent_name}': {exc}") + emit_warning(f"Failed to delete agent '{agent_name}': {exc}") return False diff --git a/code_puppy/agents/json_agent.py b/code_puppy/agents/json_agent.py index 64adfa3ca..577509c81 100644 --- a/code_puppy/agents/json_agent.py +++ b/code_puppy/agents/json_agent.py @@ -3,13 +3,19 @@ import json import logging from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Literal, Optional, TypedDict from .base_agent import BaseAgent logger = logging.getLogger(__name__) +class AgentSourceInfo(TypedDict): + path: str + source: Literal["user", "project"] + shadowed_path: Optional[str] + + class JSONAgent(BaseAgent): """Agent configured from a JSON file.""" @@ -159,20 +165,37 @@ def discover_json_agents() -> Dict[str, str]: Returns: Dict mapping agent names to their JSON file paths. """ + return { + name: info["path"] for name, info in discover_json_agents_with_sources().items() + } + + +def discover_json_agents_with_sources() -> Dict[str, AgentSourceInfo]: + """Discover JSON agents and record their source locations and any overrides. + + Like discover_json_agents(), but returns richer metadata so callers can + surface when a project agent is shadowing a user-level agent. + + Returns: + Dict mapping agent names to a dict with keys: + - "path": str — the file path of the agent that will be used + - "source": "user" | "project" — where the active agent lives + - "shadowed_path": str | None — path of the user-level agent that + was overridden by a project agent, or None if no conflict + """ from code_puppy.config import ( get_project_agents_directory, get_user_agents_directory, ) - agents: Dict[str, str] = {} - - # 1. Discover user-level agents first + # Collect user-level agents first + user_agents: Dict[str, str] = {} user_agents_dir = Path(get_user_agents_directory()) if user_agents_dir.exists() and user_agents_dir.is_dir(): for json_file in user_agents_dir.glob("*.json"): try: agent = JSONAgent(str(json_file)) - agents[agent.name] = str(json_file) + user_agents[agent.name] = str(json_file) except Exception as e: logger.debug( "Skipping invalid user agent file: %s (reason: %s: %s)", @@ -180,16 +203,16 @@ def discover_json_agents() -> Dict[str, str]: type(e).__name__, str(e), ) - continue - # 2. Discover project-level agents (overrides user agents on name collision) + # Collect project-level agents + project_agents: Dict[str, str] = {} project_agents_dir_str = get_project_agents_directory() if project_agents_dir_str is not None: project_agents_dir = Path(project_agents_dir_str) for json_file in project_agents_dir.glob("*.json"): try: agent = JSONAgent(str(json_file)) - agents[agent.name] = str(json_file) + project_agents[agent.name] = str(json_file) except Exception as e: logger.debug( "Skipping invalid project agent file: %s (reason: %s: %s)", @@ -197,6 +220,18 @@ def discover_json_agents() -> Dict[str, str]: type(e).__name__, str(e), ) - continue - return agents + result: Dict[str, AgentSourceInfo] = {} + + for name, path in user_agents.items(): + result[name] = {"path": path, "source": "user", "shadowed_path": None} + + for name, path in project_agents.items(): + shadowed = user_agents.get(name) + result[name] = { + "path": path, + "source": "project", + "shadowed_path": shadowed, + } + + return result diff --git a/code_puppy/command_line/agent_menu.py b/code_puppy/command_line/agent_menu.py index 7e12d012f..7bb3d5652 100644 --- a/code_puppy/command_line/agent_menu.py +++ b/code_puppy/command_line/agent_menu.py @@ -7,7 +7,8 @@ import asyncio import sys import unicodedata -from typing import List, Optional, Tuple +from pathlib import Path +from typing import List, NamedTuple, Optional, Tuple from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings @@ -18,8 +19,7 @@ from code_puppy.agents import ( clone_agent, delete_clone_agent, - get_agent_descriptions, - get_available_agents, + get_available_agents_with_descriptions, get_current_agent, is_clone_agent_name, ) @@ -27,6 +27,8 @@ from code_puppy.config import ( clear_agent_pinned_model, get_agent_pinned_model, + get_project_agents_directory, + get_user_agents_directory, set_agent_pinned_model, ) from code_puppy.messaging import emit_info, emit_success, emit_warning @@ -35,6 +37,19 @@ PAGE_SIZE = 10 # Agents per page +_USER_DIR_CHOICE = "User directory (~/.code_puppy/agents/)" +_PROJECT_DIR_CHOICE = "Project directory (.code_puppy/agents/)" + + +class AgentEntry(NamedTuple): + """Immutable descriptor for a single entry in the agent menu.""" + + name: str + display_name: str + description: str + source_path: Optional[str] # None for built-in Python agents + shadowed_path: Optional[str] # Set when a project agent overrides a user agent + def _sanitize_display_text(text: str) -> str: """Remove or replace characters that cause terminal rendering issues. @@ -182,6 +197,36 @@ async def _select_pinned_model(agent_name: str) -> Optional[str]: return _normalize_model_choice(choice) +async def _select_clone_location() -> Optional[Path]: + """Prompt the user to choose a directory for the cloned agent. + + Returns: + Path to the chosen agents directory, or None if cancelled. + """ + project_dir = get_project_agents_directory() + user_dir = get_user_agents_directory() + + # Skip the prompt entirely when there is no project directory — no choice to make. + if not project_dir: + return Path(user_dir) + + try: + choice = await arrow_select_async( + "Where should the cloned agent be saved?", + [_USER_DIR_CHOICE, _PROJECT_DIR_CHOICE], + ) + except KeyboardInterrupt: + emit_info("Clone cancelled") + return None + + if choice is None: + return None + + if choice == _PROJECT_DIR_CHOICE: + return Path(project_dir) + return Path(user_dir) + + def _reload_agent_if_current( agent_name: str, pinned_model: Optional[str], @@ -258,27 +303,38 @@ def _apply_pinned_model(agent_name: str, model_choice: str) -> None: emit_warning(f"Failed to apply pinned model: {exc}") -def _get_agent_entries() -> List[Tuple[str, str, str]]: - """Get all agents with their display names and descriptions. +def _get_agent_entries() -> List[AgentEntry]: + """Get all agents with their display names, descriptions, and source paths. + + Uses get_available_agents_with_descriptions() (one _discover_agents() call) + plus a single discover_json_agents_with_sources() call for path/shadow data, + for a total of two filesystem scans instead of three. Returns: - List of tuples (agent_name, display_name, description) sorted by name. + List of AgentEntry sorted alphabetically by name. """ - available = get_available_agents() - descriptions = get_agent_descriptions() + agents_metadata = get_available_agents_with_descriptions() + + # Deferred import to avoid a circular dependency at module load time. + from code_puppy.agents.json_agent import discover_json_agents_with_sources + + source_info = discover_json_agents_with_sources() entries = [] - for name, display_name in available.items(): - description = descriptions.get(name, "No description available") - entries.append((name, display_name, description)) + for name, (display_name, description) in agents_metadata.items(): + info = source_info.get(name) + source_path = info.get("path") if info else None + shadowed_path = info.get("shadowed_path") if info else None + entries.append( + AgentEntry(name, display_name, description, source_path, shadowed_path) + ) - # Sort alphabetically by agent name - entries.sort(key=lambda x: x[0].lower()) + entries.sort(key=lambda e: e.name.lower()) return entries def _render_menu_panel( - entries: List[Tuple[str, str, str]], + entries: List[AgentEntry], page: int, selected_idx: int, current_agent_name: str, @@ -309,13 +365,13 @@ def _render_menu_panel( else: # Show agents for current page for i in range(start_idx, end_idx): - name, display_name, _ = entries[i] + entry = entries[i] is_selected = i == selected_idx - is_current = name == current_agent_name - pinned_model = _get_pinned_model(name) + is_current = entry.name == current_agent_name + pinned_model = _get_pinned_model(entry.name) # Sanitize display name to avoid emoji rendering issues - safe_display_name = _sanitize_display_text(display_name) + safe_display_name = _sanitize_display_text(entry.display_name) # Build the line if is_selected: @@ -356,13 +412,15 @@ def _render_menu_panel( def _render_preview_panel( - entry: Optional[Tuple[str, str, str]], + entry: Optional[AgentEntry], current_agent_name: str, ) -> List: """Render the right preview panel with agent details. Args: - entry: Tuple of (name, display_name, description) or None + entry: AgentEntry or None. source_path is None for built-in Python + agents. shadowed_path is set when a project agent overrides a + user agent with the same name. current_agent_name: Name of the current active agent Returns: @@ -378,17 +436,16 @@ def _render_preview_panel( lines.append(("", "\n")) return lines - name, display_name, description = entry - is_current = name == current_agent_name - pinned_model = _get_pinned_model(name) + is_current = entry.name == current_agent_name + pinned_model = _get_pinned_model(entry.name) # Sanitize text to avoid emoji rendering issues - safe_display_name = _sanitize_display_text(display_name) - safe_description = _sanitize_display_text(description) + safe_display_name = _sanitize_display_text(entry.display_name) + safe_description = _sanitize_display_text(entry.description) # Agent name (identifier) lines.append(("bold", "Name: ")) - lines.append(("", name)) + lines.append(("", entry.name)) lines.append(("", "\n\n")) # Display name @@ -396,6 +453,19 @@ def _render_preview_panel( lines.append(("fg:ansicyan", safe_display_name)) lines.append(("", "\n\n")) + # Source path + lines.append(("bold", "Path: ")) + if entry.source_path: + lines.append(("fg:ansibrightblack", entry.source_path)) + if entry.shadowed_path: + lines.append(("fg:ansiyellow", " [!] overrides user agent")) + lines.append(("", "\n")) + lines.append(("bold", " ")) + lines.append(("fg:ansibrightblack", f"(shadows: {entry.shadowed_path})")) + else: + lines.append(("fg:ansibrightblack", "built-in")) + lines.append(("", "\n\n")) + # Pinned model lines.append(("bold", "Pinned Model: ")) if pinned_model: @@ -442,6 +512,24 @@ def _render_preview_panel( return lines +def _handle_delete_action(agent_name: str, current_agent_name: str) -> bool: + """Handle a delete request from the agent menu. + + Guards are enforced both here (UI layer, clone-only) and inside + delete_clone_agent() (manager layer, directory confinement). + + Returns: + True if the agent was successfully deleted. + """ + if not is_clone_agent_name(agent_name): + emit_warning("Only cloned agents can be deleted.") + return False + if agent_name == current_agent_name: + emit_warning("Cannot delete the active agent. Switch first.") + return False + return bool(delete_clone_agent(agent_name)) + + async def interactive_agent_picker() -> Optional[str]: """Show interactive terminal UI to select an agent. @@ -481,7 +569,7 @@ def refresh_entries(selected_name: Optional[str] = None) -> None: return if selected_name: - for idx, (name, _, _) in enumerate(entries): + for idx, (name, *_) in enumerate(entries): if name == selected_name: selected_idx[0] = idx break @@ -577,7 +665,7 @@ def _(event): def _(event): entry = get_current_entry() if entry: - result[0] = entry[0] # Store agent name + result[0] = entry.name event.app.exit() @kb.add("c-c") @@ -617,17 +705,21 @@ def _(event): if pending_action[0] == "pin": entry = get_current_entry() if entry: - selected_model = await _select_pinned_model(entry[0]) + selected_model = await _select_pinned_model(entry.name) if selected_model: - _apply_pinned_model(entry[0], selected_model) + _apply_pinned_model(entry.name, selected_model) continue if pending_action[0] == "clone": entry = get_current_entry() selected_name = None if entry: - cloned_name = clone_agent(entry[0]) - selected_name = cloned_name or entry[0] + target_dir = await _select_clone_location() + if target_dir is not None: + cloned_name = clone_agent(entry.name, target_dir=target_dir) + selected_name = cloned_name or entry.name + else: + selected_name = entry.name refresh_entries(selected_name=selected_name) continue @@ -635,15 +727,9 @@ def _(event): entry = get_current_entry() selected_name = None if entry: - agent_name = entry[0] - selected_name = agent_name - if not is_clone_agent_name(agent_name): - emit_warning("Only cloned agents can be deleted.") - elif agent_name == current_agent_name: - emit_warning("Cannot delete the active agent. Switch first.") - else: - if delete_clone_agent(agent_name): - selected_name = None + selected_name = entry.name + if _handle_delete_action(entry.name, current_agent_name): + selected_name = None refresh_entries(selected_name=selected_name) continue diff --git a/tests/agents/test_agent_cloning.py b/tests/agents/test_agent_cloning.py new file mode 100644 index 000000000..c7add0e0e --- /dev/null +++ b/tests/agents/test_agent_cloning.py @@ -0,0 +1,182 @@ +"""Tests for agent cloning functionality.""" + +import json + +from code_puppy.agents.agent_manager import clone_agent + + +class TestAgentCloneProjectDirectory: + """Tests for cloning agents to project directories.""" + + def test_clone_to_project_directory(self, tmp_path, monkeypatch): + """Test cloning an agent to the project directory.""" + # Setup project structure + project_agents_dir = tmp_path / ".code_puppy" / "agents" + project_agents_dir.mkdir(parents=True) + + # Create a source agent JSON file + user_agents_dir = tmp_path / "user_agents" + user_agents_dir.mkdir() + source_agent_path = user_agents_dir / "source-agent.json" + source_config = { + "name": "source-agent", + "description": "Source agent for cloning", + "system_prompt": "Test prompt", + "tools": ["read_file"], + } + with open(source_agent_path, "w") as f: + json.dump(source_config, f) + + # Mock config functions in the config module + monkeypatch.setattr( + "code_puppy.config.get_project_agents_directory", + lambda: str(project_agents_dir), + ) + monkeypatch.setattr( + "code_puppy.config.get_user_agents_directory", lambda: str(user_agents_dir) + ) + + # Mock _ask_clone_location to return project directory + monkeypatch.setattr( + "code_puppy.agents.agent_manager._ask_clone_location", + lambda: project_agents_dir, + ) + + # Mock agent registry + from code_puppy.agents.agent_manager import _AGENT_REGISTRY + + _AGENT_REGISTRY.clear() + _AGENT_REGISTRY["source-agent"] = str(source_agent_path) + + # Mock get_available_tool_names from tools module + monkeypatch.setattr( + "code_puppy.tools.get_available_tool_names", + lambda: ["read_file", "edit_file"], + ) + + # Test clone + clone_name = clone_agent("source-agent") + + # Verify clone created in project directory + assert clone_name is not None + assert clone_name == "source-agent-clone-1" + clone_path = project_agents_dir / f"{clone_name}.json" + assert clone_path.exists() + + # Verify clone config + with open(clone_path) as f: + clone_config = json.load(f) + assert clone_config["name"] == clone_name + assert clone_config["description"] == source_config["description"] + + def test_clone_to_user_directory(self, tmp_path, monkeypatch): + """Test cloning an agent to the user directory.""" + # Setup directories + user_agents_dir = tmp_path / "user_agents" + user_agents_dir.mkdir() + + # Create a source agent JSON file + source_agent_path = user_agents_dir / "source-agent.json" + source_config = { + "name": "source-agent", + "description": "Source agent", + "system_prompt": "Test", + "tools": ["read_file"], + } + with open(source_agent_path, "w") as f: + json.dump(source_config, f) + + # Mock config functions in the config module + monkeypatch.setattr( + "code_puppy.config.get_project_agents_directory", lambda: None + ) + monkeypatch.setattr( + "code_puppy.config.get_user_agents_directory", lambda: str(user_agents_dir) + ) + + # Mock _ask_clone_location to return user directory + monkeypatch.setattr( + "code_puppy.agents.agent_manager._ask_clone_location", + lambda: user_agents_dir, + ) + + # Mock agent registry + from code_puppy.agents.agent_manager import _AGENT_REGISTRY + + _AGENT_REGISTRY.clear() + _AGENT_REGISTRY["source-agent"] = str(source_agent_path) + + # Mock get_available_tool_names from tools module + monkeypatch.setattr( + "code_puppy.tools.get_available_tool_names", + lambda: ["read_file", "edit_file"], + ) + + # Test clone + clone_name = clone_agent("source-agent") + + # Verify clone created in user directory + assert clone_name is not None + clone_path = user_agents_dir / f"{clone_name}.json" + assert clone_path.exists() + + def test_clone_user_cancels_location(self, tmp_path, monkeypatch): + """Test handling when user cancels clone location selection.""" + # Setup + user_agents_dir = tmp_path / "user_agents" + user_agents_dir.mkdir() + + # Create a source agent + source_agent_path = user_agents_dir / "source-agent.json" + source_config = { + "name": "source-agent", + "description": "Source", + "system_prompt": "Test", + "tools": [], + } + with open(source_agent_path, "w") as f: + json.dump(source_config, f) + + # Mock agent registry + from code_puppy.agents.agent_manager import _AGENT_REGISTRY + + _AGENT_REGISTRY.clear() + _AGENT_REGISTRY["source-agent"] = str(source_agent_path) + + # Disable project agent discovery to avoid non-deterministic CWD reads + monkeypatch.setattr( + "code_puppy.config.get_project_agents_directory", lambda: None + ) + monkeypatch.setattr( + "code_puppy.config.get_user_agents_directory", + lambda: str(user_agents_dir), + ) + + # Mock: User cancels + monkeypatch.setattr( + "code_puppy.agents.agent_manager._ask_clone_location", lambda: None + ) + + # Test + clone_name = clone_agent("source-agent") + + # Verify clone was cancelled + assert clone_name is None + + def test_clone_nonexistent_agent(self, monkeypatch): + """Test cloning a non-existent agent.""" + # Mock empty registry + from code_puppy.agents.agent_manager import _AGENT_REGISTRY + + _AGENT_REGISTRY.clear() + + # Mock _ask_clone_location (shouldn't be called) + monkeypatch.setattr( + "code_puppy.agents.agent_manager._ask_clone_location", lambda: None + ) + + # Test + clone_name = clone_agent("nonexistent-agent") + + # Verify + assert clone_name is None diff --git a/tests/agents/test_agent_creator_agent.py b/tests/agents/test_agent_creator_agent.py index 506bcc381..1219956d4 100644 --- a/tests/agents/test_agent_creator_agent.py +++ b/tests/agents/test_agent_creator_agent.py @@ -71,6 +71,11 @@ def test_get_system_prompt_injects_agents_directory(self, monkeypatch): lambda: mock_dir, ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_project_agents_directory", + lambda: None, + ) + monkeypatch.setattr( "code_puppy.agents.agent_creator_agent.ModelFactory.load_config", lambda: {} ) @@ -78,8 +83,10 @@ def test_get_system_prompt_injects_agents_directory(self, monkeypatch): agent = AgentCreatorAgent() prompt = agent.get_system_prompt() - # Verify the agents directory is mentioned in file creation section - assert f"Save to the agents directory: `{mock_dir}`" in prompt + # Verify the user agents directory is mentioned in file creation section + assert mock_dir in prompt + # Project directory option must NOT appear when the directory doesn't exist + assert "Project directory" not in prompt def test_get_system_prompt_injects_model_inventory(self, monkeypatch): """Test that get_system_prompt() injects model inventory from ModelFactory.load_config().""" @@ -140,6 +147,11 @@ def test_get_system_prompt_comprehensive_injection(self, monkeypatch): lambda: mock_agents_dir, ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_project_agents_directory", + lambda: None, + ) + monkeypatch.setattr( "code_puppy.agents.agent_creator_agent.ModelFactory.load_config", lambda: mock_models_config, @@ -152,8 +164,10 @@ def test_get_system_prompt_comprehensive_injection(self, monkeypatch): for tool in mock_tools: assert f"**{tool}**" in prompt - # Verify agents directory is injected - assert f"Save to the agents directory: `{mock_agents_dir}`" in prompt + # Verify user agents directory is injected + assert mock_agents_dir in prompt + # Project directory must NOT appear when directory doesn't exist + assert "Project directory" not in prompt # Verify models are injected for model_name, model_info in mock_models_config.items(): @@ -211,3 +225,75 @@ def test_get_user_prompt(self): agent = AgentCreatorAgent() expected = "Hi! I'm the Agent Creator 🏗️ Let's build an awesome agent together!" assert agent.get_user_prompt() == expected + + +class TestAgentCreatorProjectDirectory: + """Tests for agent creation in project directories. + + Note: the Python-side create_agent_json / ask_save_location / get_agent_file_path + methods were removed as dead code — the LLM is instructed to call ask_user_question + and edit_file directly, bypassing those Python helpers entirely. Tests for the + system-prompt content (expanded project path) live in TestAgentCreatorSystemPrompt. + """ + + def test_system_prompt_contains_expanded_project_path(self, tmp_path, monkeypatch): + """System prompt must use the fully-expanded project path, not a relative one. + + Regression test for: project directory was shown as '.code_puppy/agents/' (relative) + while the user directory was expanded. The LLM prompt now injects the absolute path + from get_project_agents_directory() so the LLM's edit_file calls resolve correctly. + """ + project_dir = tmp_path / ".code_puppy" / "agents" + project_dir.mkdir(parents=True) + + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_project_agents_directory", + lambda: str(project_dir), + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_available_tool_names", + lambda: ["read_file", "edit_file"], + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_user_agents_directory", + lambda: "/mock/user/agents", + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.ModelFactory.load_config", + lambda: {}, + ) + + creator = AgentCreatorAgent() + prompt = creator.get_system_prompt() + + # The expanded absolute path must appear in the prompt. + assert str(project_dir) in prompt + # The bare relative placeholder must NOT appear in the prompt. + assert "`.code_puppy/agents/agent-name.json`" not in prompt + + def test_system_prompt_omits_project_directory_when_absent(self, monkeypatch): + """When no project directory exists, the project directory option is omitted.""" + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_project_agents_directory", + lambda: None, + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_available_tool_names", + lambda: ["read_file"], + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.get_user_agents_directory", + lambda: "/mock/user/agents", + ) + monkeypatch.setattr( + "code_puppy.agents.agent_creator_agent.ModelFactory.load_config", + lambda: {}, + ) + + creator = AgentCreatorAgent() + prompt = creator.get_system_prompt() + + # Project directory option must not appear — business users should not see it + assert "Project directory" not in prompt + # User directory must still be offered + assert "/mock/user/agents" in prompt diff --git a/tests/agents/test_json_agent_sources.py b/tests/agents/test_json_agent_sources.py new file mode 100644 index 000000000..5dc40aafc --- /dev/null +++ b/tests/agents/test_json_agent_sources.py @@ -0,0 +1,237 @@ +"""Unit tests for discover_json_agents_with_sources().""" + +import json +from unittest.mock import patch + +from code_puppy.agents.json_agent import discover_json_agents_with_sources + + +def _make_agent_file(directory, name, description="Test agent"): + config = { + "name": name, + "description": description, + "system_prompt": f"You are {name}", + "tools": ["list_files"], + } + agent_file = directory / f"{name}.json" + agent_file.write_text(json.dumps(config)) + return agent_file + + +class TestDiscoverJsonAgentsWithSources: + """Tests for discover_json_agents_with_sources().""" + + def test_user_only_agent(self, tmp_path): + """User-only agent has source='user' and shadowed_path=None.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + user_file = _make_agent_file(user_dir, "my-agent") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch("code_puppy.config.get_project_agents_directory", return_value=None), + ): + result = discover_json_agents_with_sources() + + assert "my-agent" in result + info = result["my-agent"] + assert info["path"] == str(user_file) + assert info["source"] == "user" + assert info["shadowed_path"] is None + + def test_project_only_agent(self, tmp_path): + """Project-only agent has source='project' and shadowed_path=None.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + project_file = _make_agent_file(project_dir, "team-agent") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert "team-agent" in result + info = result["team-agent"] + assert info["path"] == str(project_file) + assert info["source"] == "project" + assert info["shadowed_path"] is None + + def test_project_overrides_user(self, tmp_path): + """When a project agent shadows a user agent, source='project' and shadowed_path points to user file.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + + user_file = _make_agent_file(user_dir, "shared-agent", "User version") + project_file = _make_agent_file(project_dir, "shared-agent", "Project version") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert "shared-agent" in result + info = result["shared-agent"] + assert info["path"] == str(project_file) + assert info["source"] == "project" + assert info["shadowed_path"] == str(user_file) + + def test_both_directories_no_collision(self, tmp_path): + """Agents from both directories are merged; non-colliding agents keep their source.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + + user_file = _make_agent_file(user_dir, "user-only") + project_file = _make_agent_file(project_dir, "project-only") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert len(result) == 2 + assert result["user-only"]["source"] == "user" + assert result["user-only"]["path"] == str(user_file) + assert result["user-only"]["shadowed_path"] is None + assert result["project-only"]["source"] == "project" + assert result["project-only"]["path"] == str(project_file) + assert result["project-only"]["shadowed_path"] is None + + def test_invalid_user_agent_skipped(self, tmp_path): + """Invalid JSON files in the user directory are skipped gracefully.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + + valid_file = _make_agent_file(user_dir, "valid-agent") + (user_dir / "bad-syntax.json").write_text("{invalid json}") + (user_dir / "missing-fields.json").write_text('{"name": "incomplete"}') + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch("code_puppy.config.get_project_agents_directory", return_value=None), + ): + result = discover_json_agents_with_sources() + + assert len(result) == 1 + assert "valid-agent" in result + assert result["valid-agent"]["path"] == str(valid_file) + + def test_invalid_project_agent_skipped(self, tmp_path): + """Invalid JSON files in the project directory are skipped gracefully.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + + _make_agent_file(project_dir, "valid-proj") + (project_dir / "bad.json").write_text("{not valid}") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert len(result) == 1 + assert "valid-proj" in result + assert result["valid-proj"]["source"] == "project" + + def test_empty_directories(self, tmp_path): + """Empty user and project directories return an empty dict.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert result == {} + + def test_no_project_directory(self, tmp_path): + """When get_project_agents_directory returns None, only user agents are returned.""" + user_dir = tmp_path / "user_agents" + user_dir.mkdir() + user_file = _make_agent_file(user_dir, "user-agent") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value=str(user_dir), + ), + patch("code_puppy.config.get_project_agents_directory", return_value=None), + ): + result = discover_json_agents_with_sources() + + assert list(result.keys()) == ["user-agent"] + assert result["user-agent"]["source"] == "user" + assert result["user-agent"]["path"] == str(user_file) + + def test_nonexistent_user_directory(self, tmp_path): + """When the user agents directory doesn't exist, return only project agents.""" + project_dir = tmp_path / "project_agents" + project_dir.mkdir() + _make_agent_file(project_dir, "proj-agent") + + with ( + patch( + "code_puppy.config.get_user_agents_directory", + return_value="/nonexistent/path", + ), + patch( + "code_puppy.config.get_project_agents_directory", + return_value=str(project_dir), + ), + ): + result = discover_json_agents_with_sources() + + assert list(result.keys()) == ["proj-agent"] + assert result["proj-agent"]["source"] == "project" + assert result["proj-agent"]["shadowed_path"] is None diff --git a/tests/command_line/test_agent_menu.py b/tests/command_line/test_agent_menu.py index c1f0400ef..d40832f35 100644 --- a/tests/command_line/test_agent_menu.py +++ b/tests/command_line/test_agent_menu.py @@ -8,11 +8,15 @@ from code_puppy.command_line.agent_menu import ( PAGE_SIZE, + AgentEntry, + _PROJECT_DIR_CHOICE, _apply_pinned_model, _get_agent_entries, _get_pinned_model, + _handle_delete_action, _render_menu_panel, _render_preview_panel, + _select_clone_location, ) @@ -42,46 +46,52 @@ def test_page_size_value(self): class TestGetAgentEntries: """Test the _get_agent_entries function.""" - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_returns_empty_list_when_no_agents(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_returns_empty_list_when_no_agents(self, mock_combined, _mock_sources): """Test that empty list is returned when no agents are available.""" - mock_available.return_value = {} - mock_descriptions.return_value = {} + mock_combined.return_value = {} result = _get_agent_entries() assert result == [] - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_returns_single_agent(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_returns_single_agent(self, mock_combined, _mock_sources): """Test that single agent is returned correctly.""" - mock_available.return_value = {"code_puppy": "Code Puppy 🐶"} - mock_descriptions.return_value = {"code_puppy": "A friendly coding assistant."} + mock_combined.return_value = { + "code_puppy": ("Code Puppy 🐶", "A friendly coding assistant.") + } result = _get_agent_entries() assert len(result) == 1 - assert result[0] == ( + assert result[0] == AgentEntry( "code_puppy", "Code Puppy 🐶", "A friendly coding assistant.", + None, + None, ) - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_returns_multiple_agents_sorted(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_returns_multiple_agents_sorted(self, mock_combined, _mock_sources): """Test that multiple agents are returned sorted alphabetically.""" - mock_available.return_value = { - "zebra_agent": "Zebra Agent", - "alpha_agent": "Alpha Agent", - "beta_agent": "Beta Agent", - } - mock_descriptions.return_value = { - "zebra_agent": "Zebra description", - "alpha_agent": "Alpha description", - "beta_agent": "Beta description", + mock_combined.return_value = { + "zebra_agent": ("Zebra Agent", "Zebra description"), + "alpha_agent": ("Alpha Agent", "Alpha description"), + "beta_agent": ("Beta Agent", "Beta description"), } result = _get_agent_entries() @@ -92,46 +102,49 @@ def test_returns_multiple_agents_sorted(self, mock_available, mock_descriptions) assert result[1][0] == "beta_agent" assert result[2][0] == "zebra_agent" - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_handles_missing_description(self, mock_available, mock_descriptions): - """Test that missing descriptions get default value.""" - mock_available.return_value = {"test_agent": "Test Agent"} - mock_descriptions.return_value = {} # No description for this agent + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_handles_missing_description(self, mock_combined, _mock_sources): + """Test that 'No description available' comes from the manager, not this function.""" + mock_combined.return_value = { + "test_agent": ("Test Agent", "No description available") + } result = _get_agent_entries() assert len(result) == 1 - assert result[0] == ("test_agent", "Test Agent", "No description available") - - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_handles_extra_descriptions(self, mock_available, mock_descriptions): - """Test that extra descriptions (without matching agents) are ignored.""" - mock_available.return_value = {"agent1": "Agent One"} - mock_descriptions.return_value = { - "agent1": "Description for agent1", - "agent2": "Description for non-existent agent", - } + assert result[0] == AgentEntry( + "test_agent", "Test Agent", "No description available", None, None + ) + + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_handles_single_agent(self, mock_combined, _mock_sources): + """Test that only agents present in combined metadata are returned.""" + mock_combined.return_value = {"agent1": ("Agent One", "Description for agent1")} result = _get_agent_entries() assert len(result) == 1 assert result[0][0] == "agent1" - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_sorts_case_insensitive(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_sorts_case_insensitive(self, mock_combined, _mock_sources): """Test that sorting is case-insensitive.""" - mock_available.return_value = { - "UPPER_AGENT": "Upper Agent", - "lower_agent": "Lower Agent", - "Mixed_Agent": "Mixed Agent", - } - mock_descriptions.return_value = { - "UPPER_AGENT": "Upper desc", - "lower_agent": "Lower desc", - "Mixed_Agent": "Mixed desc", + mock_combined.return_value = { + "UPPER_AGENT": ("Upper Agent", "Upper desc"), + "lower_agent": ("Lower Agent", "Lower desc"), + "Mixed_Agent": ("Mixed Agent", "Mixed desc"), } result = _get_agent_entries() @@ -141,16 +154,18 @@ def test_sorts_case_insensitive(self, mock_available, mock_descriptions): assert result[1][0] == "Mixed_Agent" assert result[2][0] == "UPPER_AGENT" - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_returns_more_than_page_size(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_returns_more_than_page_size(self, mock_combined, _mock_sources): """Test handling of more agents than PAGE_SIZE.""" # Create 15 agents (more than PAGE_SIZE of 10) - agents = {f"agent_{i:02d}": f"Agent {i:02d}" for i in range(15)} - descriptions = {f"agent_{i:02d}": f"Description {i:02d}" for i in range(15)} - - mock_available.return_value = agents - mock_descriptions.return_value = descriptions + mock_combined.return_value = { + f"agent_{i:02d}": (f"Agent {i:02d}", f"Description {i:02d}") + for i in range(15) + } result = _get_agent_entries() @@ -178,7 +193,11 @@ def test_renders_single_agent(self): Note: Emojis are stripped from display names for clean terminal rendering. """ - entries = [("code_puppy", "Code Puppy 🐶", "A friendly assistant.")] + entries = [ + AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) + ] result = _render_menu_panel( entries, page=0, selected_idx=0, current_agent_name="" @@ -192,8 +211,8 @@ def test_renders_single_agent(self): def test_highlights_selected_agent(self): """Test that selected agent is highlighted with indicator.""" entries = [ - ("agent1", "Agent One", "Description 1"), - ("agent2", "Agent Two", "Description 2"), + AgentEntry("agent1", "Agent One", "Description 1", None, None), + AgentEntry("agent2", "Agent Two", "Description 2", None, None), ] result = _render_menu_panel( @@ -207,8 +226,8 @@ def test_highlights_selected_agent(self): def test_marks_current_agent(self): """Test that current agent is marked.""" entries = [ - ("agent1", "Agent One", "Description 1"), - ("agent2", "Agent Two", "Description 2"), + AgentEntry("agent1", "Agent One", "Description 1", None, None), + AgentEntry("agent2", "Agent Two", "Description 2", None, None), ] result = _render_menu_panel( @@ -222,7 +241,7 @@ def test_marks_current_agent(self): def test_shows_pinned_model_marker(self, mock_pinned_model): """Test that pinned models are displayed in the menu.""" mock_pinned_model.return_value = "gpt-4" - entries = [("agent1", "Agent One", "Description 1")] + entries = [AgentEntry("agent1", "Agent One", "Description 1", None, None)] result = _render_menu_panel( entries, page=0, selected_idx=0, current_agent_name="" @@ -235,7 +254,7 @@ def test_shows_pinned_model_marker(self, mock_pinned_model): def test_unpinned_model_shows_no_marker(self, mock_pinned_model): """Test that unpinned agents show no pinned model marker.""" mock_pinned_model.return_value = None - entries = [("agent1", "Agent One", "Description 1")] + entries = [AgentEntry("agent1", "Agent One", "Description 1", None, None)] result = _render_menu_panel( entries, page=0, selected_idx=0, current_agent_name="" @@ -254,7 +273,8 @@ def test_pagination_page_zero(self): """Test pagination shows correct info for page 0.""" # Create 25 agents for multiple pages entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") for i in range(25) + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) + for i in range(25) ] result = _render_menu_panel( @@ -270,7 +290,8 @@ def test_pagination_page_zero(self): def test_pagination_page_one(self): """Test pagination shows correct info for page 1.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") for i in range(25) + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) + for i in range(25) ] result = _render_menu_panel( @@ -286,7 +307,8 @@ def test_pagination_page_one(self): def test_pagination_last_page(self): """Test pagination shows correct info for last page.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") for i in range(25) + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) + for i in range(25) ] result = _render_menu_panel( @@ -327,7 +349,8 @@ def test_shows_agents_header(self): def test_selected_agent_on_second_page(self): """Test selection highlighting works on second page.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") for i in range(15) + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) + for i in range(15) ] # Select agent 12 on page 1 (indices 10-14) @@ -342,8 +365,8 @@ def test_selected_agent_on_second_page(self): def test_current_agent_indicator_with_selection(self): """Test that both selection and current markers can appear.""" entries = [ - ("agent1", "Agent One", "Description 1"), - ("agent2", "Agent Two", "Description 2"), + AgentEntry("agent1", "Agent One", "Description 1", None, None), + AgentEntry("agent2", "Agent Two", "Description 2", None, None), ] # Select agent2 which is also the current agent @@ -369,7 +392,9 @@ def test_renders_no_selection(self): def test_renders_agent_name(self): """Test that agent name is displayed.""" - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="") @@ -382,7 +407,9 @@ def test_renders_display_name(self): Note: Emojis are stripped from display names for clean terminal rendering. """ - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="") @@ -395,7 +422,9 @@ def test_renders_display_name(self): def test_renders_pinned_model(self, mock_pinned_model): """Test that pinned model is shown in the preview panel.""" mock_pinned_model.return_value = "gpt-4" - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="") @@ -407,7 +436,9 @@ def test_renders_pinned_model(self, mock_pinned_model): def test_renders_unpinned_model_shows_default(self, mock_pinned_model): """Test that unpinned model shows 'default' in preview.""" mock_pinned_model.return_value = None - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="") @@ -417,7 +448,13 @@ def test_renders_unpinned_model_shows_default(self, mock_pinned_model): def test_renders_description(self): """Test that description is displayed.""" - entry = ("code_puppy", "Code Puppy 🐶", "A friendly coding assistant dog.") + entry = AgentEntry( + "code_puppy", + "Code Puppy 🐶", + "A friendly coding assistant dog.", + None, + None, + ) result = _render_preview_panel(entry, current_agent_name="") @@ -427,7 +464,9 @@ def test_renders_description(self): def test_renders_status_not_active(self): """Test that status shows 'Not active' for non-current agent.""" - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="other_agent") @@ -437,7 +476,9 @@ def test_renders_status_not_active(self): def test_renders_status_currently_active(self): """Test that status shows active for current agent.""" - entry = ("code_puppy", "Code Puppy 🐶", "A friendly assistant.") + entry = AgentEntry( + "code_puppy", "Code Puppy 🐶", "A friendly assistant.", None, None + ) result = _render_preview_panel(entry, current_agent_name="code_puppy") @@ -448,7 +489,7 @@ def test_renders_status_currently_active(self): def test_renders_header(self): """Test that AGENT DETAILS header is displayed.""" - entry = ("agent1", "Agent One", "Description") + entry = AgentEntry("agent1", "Agent One", "Description", None, None) result = _render_preview_panel(entry, current_agent_name="") @@ -457,10 +498,12 @@ def test_renders_header(self): def test_handles_multiline_description(self): """Test handling of descriptions with multiple lines.""" - entry = ( + entry = AgentEntry( "test_agent", "Test Agent", "First line of description.\nSecond line of description.\nThird line.", + None, + None, ) result = _render_preview_panel(entry, current_agent_name="") @@ -476,7 +519,7 @@ def test_handles_long_description(self): "This is a very long description that should be wrapped appropriately " "to fit within the preview panel boundaries without causing display issues." ) - entry = ("test_agent", "Test Agent", long_description) + entry = AgentEntry("test_agent", "Test Agent", long_description, None, None) result = _render_preview_panel(entry, current_agent_name="") @@ -487,7 +530,7 @@ def test_handles_long_description(self): def test_handles_empty_description(self): """Test handling of empty description.""" - entry = ("test_agent", "Test Agent", "") + entry = AgentEntry("test_agent", "Test Agent", "", None, None) result = _render_preview_panel(entry, current_agent_name="") @@ -499,10 +542,12 @@ def test_handles_empty_description(self): def test_handles_description_with_special_characters(self): """Test handling of descriptions with emojis and special chars.""" - entry = ( + entry = AgentEntry( "emoji_agent", "Emoji Agent 🎉", "An agent with emojis 🐶🐱 and special chars: <>&", + None, + None, ) result = _render_preview_panel(entry, current_agent_name="") @@ -514,19 +559,23 @@ def test_handles_description_with_special_characters(self): class TestGetAgentEntriesIntegration: """Integration-style tests for _get_agent_entries behavior.""" - @patch("code_puppy.command_line.agent_menu.get_agent_descriptions") - @patch("code_puppy.command_line.agent_menu.get_available_agents") - def test_typical_usage_scenario(self, mock_available, mock_descriptions): + @patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ) + @patch("code_puppy.command_line.agent_menu.get_available_agents_with_descriptions") + def test_typical_usage_scenario(self, mock_combined, _mock_sources): """Test a typical usage scenario with realistic agent data.""" - mock_available.return_value = { - "code_puppy": "Code Puppy 🐶", - "pack_leader": "Pack Leader 🦮", - "code_reviewer": "Code Reviewer 🔍", - } - mock_descriptions.return_value = { - "code_puppy": "A friendly AI coding assistant.", - "pack_leader": "Coordinates the pack of specialized agents.", - "code_reviewer": "Reviews code for quality and best practices.", + mock_combined.return_value = { + "code_puppy": ("Code Puppy 🐶", "A friendly AI coding assistant."), + "pack_leader": ( + "Pack Leader 🦮", + "Coordinates the pack of specialized agents.", + ), + "code_reviewer": ( + "Code Reviewer 🔍", + "Reviews code for quality and best practices.", + ), } result = _get_agent_entries() @@ -537,11 +586,13 @@ def test_typical_usage_scenario(self, mock_available, mock_descriptions): assert result[1][0] == "code_reviewer" assert result[2][0] == "pack_leader" - # Check full tuple structure - assert result[0] == ( + # Check full AgentEntry structure + assert result[0] == AgentEntry( "code_puppy", "Code Puppy 🐶", "A friendly AI coding assistant.", + None, + None, ) @@ -551,7 +602,7 @@ class TestRenderPanelEdgeCases: def test_menu_panel_with_exact_page_size_entries(self): """Test menu panel when entries exactly match PAGE_SIZE.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) for i in range(PAGE_SIZE) ] @@ -566,7 +617,7 @@ def test_menu_panel_with_exact_page_size_entries(self): def test_menu_panel_with_page_size_plus_one(self): """Test menu panel when entries are PAGE_SIZE + 1.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) for i in range(PAGE_SIZE + 1) ] @@ -581,7 +632,8 @@ def test_menu_panel_with_page_size_plus_one(self): def test_menu_panel_last_item_on_page_selected(self): """Test selection of last item on a page.""" entries = [ - (f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}") for i in range(15) + AgentEntry(f"agent_{i:02d}", f"Agent {i:02d}", f"Desc {i:02d}", None, None) + for i in range(15) ] # Select the last item on page 0 (index 9) @@ -595,7 +647,9 @@ def test_menu_panel_last_item_on_page_selected(self): def test_preview_panel_with_no_description_default(self): """Test preview panel shows default description.""" - entry = ("minimal_agent", "Minimal Agent", "No description available") + entry = AgentEntry( + "minimal_agent", "Minimal Agent", "No description available", None, None + ) result = _render_preview_panel(entry, current_agent_name="") @@ -608,7 +662,7 @@ class TestMenuPanelStyling: def test_styling_includes_green_for_selection(self): """Test that selection styling uses green color.""" - entries = [("agent1", "Agent One", "Description")] + entries = [AgentEntry("agent1", "Agent One", "Description", None, None)] result = _render_menu_panel( entries, page=0, selected_idx=0, current_agent_name="" @@ -621,7 +675,7 @@ def test_styling_includes_green_for_selection(self): def test_styling_includes_cyan_for_current(self): """Test that current agent marker uses cyan color.""" - entries = [("agent1", "Agent One", "Description")] + entries = [AgentEntry("agent1", "Agent One", "Description", None, None)] result = _render_menu_panel( entries, page=0, selected_idx=0, current_agent_name="agent1" @@ -638,7 +692,7 @@ class TestPreviewPanelStyling: def test_styling_for_active_status(self): """Test that active status uses appropriate styling.""" - entry = ("agent1", "Agent One", "Description") + entry = AgentEntry("agent1", "Agent One", "Description", None, None) result = _render_preview_panel(entry, current_agent_name="agent1") @@ -649,7 +703,7 @@ def test_styling_for_active_status(self): def test_styling_for_inactive_status(self): """Test that inactive status uses dimmed styling.""" - entry = ("agent1", "Agent One", "Description") + entry = AgentEntry("agent1", "Agent One", "Description", None, None) result = _render_preview_panel(entry, current_agent_name="other_agent") @@ -866,3 +920,331 @@ def test_handles_json_agent_write_error( # Should emit a warning instead of crashing assert mock_emit_warning.called + + +class TestRenderPreviewPanelPath: + """Test that the preview panel shows the agent source path.""" + + def test_shows_path_field_for_json_agent(self): + """Test that Path: field is displayed for JSON agents.""" + entry = AgentEntry( + "my-agent", + "My Agent", + "Does things.", + "/home/user/.code_puppy/agents/my-agent.json", + None, + ) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + assert "Path:" in text + assert "/home/user/.code_puppy/agents/my-agent.json" in text + + def test_shows_builtin_for_python_agent(self): + """Test that 'built-in' is shown when source path is None.""" + entry = AgentEntry("code-puppy", "Code Puppy", "General assistant.", None, None) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + assert "Path:" in text + assert "built-in" in text + + def test_path_field_between_display_name_and_pinned_model(self): + """Test that Path: appears after Display Name and before Pinned Model.""" + entry = AgentEntry( + "my-agent", "My Agent", "Does things.", "/some/path/my-agent.json", None + ) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + display_pos = text.index("Display Name:") + path_pos = text.index("Path:") + pinned_pos = text.index("Pinned Model:") + assert display_pos < path_pos < pinned_pos + + def test_path_uses_project_dir(self): + """Test that a project-level agent path is shown correctly.""" + entry = AgentEntry( + "team-agent", + "Team Agent", + "Shared agent.", + "/project/.code_puppy/agents/team-agent.json", + None, + ) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + assert "/project/.code_puppy/agents/team-agent.json" in text + + def test_shows_shadowed_path_when_project_overrides_user(self): + """Test that both paths and a warning are shown when a project agent shadows a user agent.""" + user_path = "/home/user/.code_puppy/agents/readme-writer.json" + project_path = "/project/.code_puppy/agents/readme-writer.json" + entry = AgentEntry( + "readme-writer", "Readme Writer", "Writes readmes.", project_path, user_path + ) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + assert project_path in text + assert user_path in text + assert "overrides" in text + assert "shadows" in text + + def test_no_shadow_warning_when_no_conflict(self): + """Test that no shadow warning appears when there is no override.""" + entry = AgentEntry( + "my-agent", + "My Agent", + "Does things.", + "/home/user/.code_puppy/agents/my-agent.json", + None, + ) + + result = _render_preview_panel(entry, current_agent_name="") + + text = _get_text_from_formatted(result) + assert "overrides" not in text + assert "shadows" not in text + + def test_menu_row_no_warning_badge_for_shadowed_agent(self): + """Test that the menu row does NOT show a warning badge (warning is details-only).""" + entries = [ + AgentEntry( + "readme-writer", + "Readme Writer", + "desc", + "/project/.code_puppy/agents/readme-writer.json", + "/home/user/.code_puppy/agents/readme-writer.json", + ), + ] + + result = _render_menu_panel( + entries, page=0, selected_idx=0, current_agent_name="" + ) + + text = _get_text_from_formatted(result) + assert "overrides" not in text + + def test_get_agent_entries_includes_path_for_json_agent(self): + """Test that _get_agent_entries includes the source path from discover_json_agents_with_sources.""" + fake_path = "/home/user/.code_puppy/agents/custom.json" + + with ( + patch( + "code_puppy.command_line.agent_menu.get_available_agents_with_descriptions", + return_value={"custom": ("Custom Agent", "A custom agent.")}, + ), + patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={ + "custom": { + "path": fake_path, + "source": "user", + "shadowed_path": None, + } + }, + ), + ): + result = _get_agent_entries() + + assert len(result) == 1 + assert result[0] == AgentEntry( + "custom", "Custom Agent", "A custom agent.", fake_path, None + ) + + def test_get_agent_entries_path_is_none_for_python_agent(self): + """Test that _get_agent_entries returns None path for built-in Python agents.""" + with ( + patch( + "code_puppy.command_line.agent_menu.get_available_agents_with_descriptions", + return_value={"code-puppy": ("Code Puppy", "Default agent.")}, + ), + patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={}, + ), + ): + result = _get_agent_entries() + + assert len(result) == 1 + assert result[0].source_path is None + assert result[0].shadowed_path is None + + def test_get_agent_entries_includes_shadowed_path(self): + """Test that _get_agent_entries propagates the shadowed path.""" + project_path = "/project/.code_puppy/agents/my-agent.json" + user_path = "/home/user/.code_puppy/agents/my-agent.json" + + with ( + patch( + "code_puppy.command_line.agent_menu.get_available_agents_with_descriptions", + return_value={"my-agent": ("My Agent", "An agent.")}, + ), + patch( + "code_puppy.agents.json_agent.discover_json_agents_with_sources", + return_value={ + "my-agent": { + "path": project_path, + "source": "project", + "shadowed_path": user_path, + } + }, + ), + ): + result = _get_agent_entries() + + assert len(result) == 1 + assert result[0][3] == project_path + assert result[0][4] == user_path + + +class TestSelectCloneLocation: + """Test the _select_clone_location async function.""" + + @patch("code_puppy.command_line.agent_menu.arrow_select_async") + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value=None, + ) + async def test_returns_user_dir_when_selected( + self, _mock_proj, _mock_user, mock_arrow + ): + """Test that user directory Path is returned when user selects it.""" + mock_arrow.return_value = "User directory (~/.code_puppy/agents/)" + + result = await _select_clone_location() + + from pathlib import Path + + assert result == Path("/home/user/.code_puppy/agents") + + @patch("code_puppy.command_line.agent_menu.arrow_select_async") + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value="/project/.code_puppy/agents", + ) + async def test_returns_project_dir_when_selected( + self, _mock_proj, _mock_user, mock_arrow + ): + """Test that project directory Path is returned when user selects it.""" + mock_arrow.return_value = _PROJECT_DIR_CHOICE + + result = await _select_clone_location() + + from pathlib import Path + + assert result == Path("/project/.code_puppy/agents") + + @patch("code_puppy.command_line.agent_menu.arrow_select_async") + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value=None, + ) + async def test_only_user_dir_offered_without_project_dir( + self, _mock_proj, _mock_user, mock_arrow + ): + """Test that no prompt is shown and user dir is returned directly when project dir doesn't exist.""" + from pathlib import Path + + result = await _select_clone_location() + + # The picker should be skipped entirely — no question asked + mock_arrow.assert_not_called() + assert result == Path("/home/user/.code_puppy/agents") + + @patch("code_puppy.command_line.agent_menu.arrow_select_async") + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value="/project/.code_puppy/agents", + ) + async def test_both_options_offered_with_project_dir( + self, _mock_proj, _mock_user, mock_arrow + ): + """Test that both options are offered when project dir exists.""" + mock_arrow.return_value = "User directory (~/.code_puppy/agents/)" + + await _select_clone_location() + + _call_args = mock_arrow.call_args + choices_passed = _call_args[0][1] + assert len(choices_passed) == 2 + assert any("User" in c for c in choices_passed) + assert any("Project" in c for c in choices_passed) + + @patch("code_puppy.command_line.agent_menu.emit_info") + @patch( + "code_puppy.command_line.agent_menu.arrow_select_async", + side_effect=KeyboardInterrupt, + ) + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value="/project/.code_puppy/agents", + ) + async def test_returns_none_on_keyboard_interrupt( + self, _mock_proj, _mock_user, _mock_arrow, mock_emit + ): + """Test that None is returned and info emitted when user cancels with Ctrl+C.""" + result = await _select_clone_location() + + assert result is None + mock_emit.assert_called_once() + + @patch("code_puppy.command_line.agent_menu.arrow_select_async", return_value=None) + @patch( + "code_puppy.command_line.agent_menu.get_user_agents_directory", + return_value="/home/user/.code_puppy/agents", + ) + @patch( + "code_puppy.command_line.agent_menu.get_project_agents_directory", + return_value="/project/.code_puppy/agents", + ) + async def test_returns_none_when_no_choice_made( + self, _mock_proj, _mock_user, _mock_arrow + ): + """Test that None is returned when arrow_select_async returns None.""" + result = await _select_clone_location() + + assert result is None + + +class TestDeleteGuard: + """Test that the delete key ('D') enforces the clone-only guard.""" + + @patch("code_puppy.command_line.agent_menu.delete_clone_agent") + @patch("code_puppy.command_line.agent_menu.emit_warning") + @patch("code_puppy.command_line.agent_menu.is_clone_agent_name", return_value=False) + def test_delete_non_clone_json_agent_emits_warning( + self, _mock_is_clone, mock_emit_warning, mock_delete + ): + """D on a non-clone JSON agent shows the warning and does NOT delete.""" + result = _handle_delete_action("my-json-agent", current_agent_name="code-puppy") + + assert result is False + mock_emit_warning.assert_called_once_with("Only cloned agents can be deleted.") + mock_delete.assert_not_called()