"""Tests for CLI commands.

Covers run, spawn, list, status, collect, kill, approve/reject/resume,
monitor, serve help, and talk commands using Click CliRunner.

Uses fixtures from conftest.py, mocks LLM calls, and verifies:
- JSON output structure
- Exit codes aligned with docs/INTERFACES.md
- State transitions and error handling
"""

import json
import os
import re
import signal
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner
from returns.result import Failure, Success

from anima.core.schemas import (
    AgentResult,
    AgentState,
    AgentStateData,
    JobSpec,
    PersonaSpec,
    TokenUsage,
)
from anima.app.launch_service import LaunchResult
from anima.shell.cli import EXIT_AGENT_NOT_FOUND, main
from anima.shell.cli_commands.run_spawn import (
    _resolve_spawn_lifecycle_and_completion_policy,
)
from anima.shell.state import (
    append_log,
    create_agent_state,
    get_agent_dir,
    read_approval_response,
    read_state,
    write_result,
    write_state,
)
from tests.fixtures.launch_control_services import (
    make_run_spawn_resolution_regression_fixture,
)
from tests.fixtures.execution_contract_resolution import (
    make_consumer_conformance_fixture,
)


EXPECTED_RUN_HELP = """Usage: main run [OPTIONS] TASK

  Run a one-off (ephemeral) agent task. Blocks until completion or timeout.

Options:
  --persona-file PATH             PersonaSpec JSON file (optional if --system-
                                  prompt provided)
  -s, --system-prompt TEXT        Inline system prompt (alternative to
                                  --persona-file)
  --job-file PATH                 JobSpec JSON file
  --model TEXT                    LLM model identifier (overrides
                                  PersonaSpec.model)
  --llm-base-url TEXT             LLM API base URL
  --mcp TEXT                      MCP server: 'command args' or http(s)://url
  --tools TEXT                    Tool preset names from config (e.g. --tools
                                  shell,web or --tools shell --tools web)
  --timeout TEXT                  Wall clock timeout (e.g., 5m, 2h)
  --max-turns INTEGER             Max agent turns
  --max-tokens INTEGER            Token budget
  --context-mode [isolated|shared]
                                  Context mode
  --context-bundle PATH           ContextBundle JSON file
  --instance-id TEXT              Idempotency key (auto-generated if not
                                  provided)
  --completion-policy [explicit|auto]
                                  Completion policy
  --token-file PATH               CapabilityToken JSON file
  --json / --no-json              Machine-readable output
  --verbose / --quiet             Show run panels (default: quiet)
  --sandbox / --no-sandbox        Enable sandbox for tool execution (default:
                                  enabled)
  --sandbox-allow TEXT            Sandbox capability (e.g., fs:/tmp, net).
                                  Repeatable. Implies --sandbox.
  --rm / --no-rm                  Delete agent state after run (default: --rm).
                                  Use --no-rm to preserve.
  --help                          Show this message and exit.
"""

EXPECTED_SPAWN_HELP = """Usage: main spawn [OPTIONS] TASK

  Spawn a background agent. Returns immediately with instance_id.

Options:
  --persona-file PATH             PersonaSpec JSON file (optional if --system-
                                  prompt provided)
  -s, --system-prompt TEXT        Inline system prompt (alternative to
                                  --persona-file)
  --job-file PATH                 JobSpec JSON file
  --model TEXT                    LLM model identifier (overrides
                                  PersonaSpec.model)
  --llm-base-url TEXT             LLM API base URL
  --mcp TEXT                      MCP server: 'command args' or http(s)://url
  --tools TEXT                    Tool preset names from config (e.g. --tools
                                  shell,web or --tools shell --tools web)
  --timeout TEXT                  Wall clock timeout (e.g., 5m, 2h)
  --max-turns INTEGER             Max agent turns
  --max-tokens INTEGER            Token budget
  --context-mode [isolated|shared]
                                  Context mode
  --context-bundle PATH           ContextBundle JSON file
  --instance-id TEXT              Idempotency key
  --completion-policy [explicit|auto]
                                  Completion policy
  --token-file PATH               CapabilityToken JSON file
  --lifecycle [ephemeral|daemon]  Agent lifecycle type
  --json                          Machine-readable output
  --sandbox / --no-sandbox        Enable sandbox for tool execution (default:
                                  enabled)
  --sandbox-allow TEXT            Sandbox capability (e.g., fs:/tmp, net).
                                  Repeatable. Implies --sandbox.
  --rm                            Delete agent state after completion (default:
                                  preserve state)
  --help                          Show this message and exit.
"""


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def cli_runner() -> CliRunner:
    """Create a CliRunner instance for testing."""
    return CliRunner()


@pytest.fixture
def persona_file(tmp_path: Path) -> Path:
    """Create a test persona file."""
    persona = {
        "name": "test-persona",
        "prompt": "You are a test assistant.",
        "model": "test-model",
        "spec_version": "0.1.0",
    }
    file_path = tmp_path / "persona.json"
    file_path.write_text(json.dumps(persona))
    return file_path


@pytest.fixture
def job_file(tmp_path: Path) -> Path:
    """Create a test job file."""
    job = {
        "persona_ref": "test-persona@test-digest",
        "instance_id": "inst_test123",
        "job_version": "0.1.0",
    }
    file_path = tmp_path / "job.json"
    file_path.write_text(json.dumps(job))
    return file_path


@pytest.fixture
def token_file(tmp_path: Path) -> Path:
    """Create a valid capability token file."""
    token = {
        "token_id": "tok_test123",
        "tools_profile": "default",
        "persona_ref": "test-persona@test-digest",
        "instance_id": "inst_test123",
        "max_depth": 3,
        "issued_at": "2026-03-01T10:00:00Z",
        "expires_at": "2026-03-01T11:00:00Z",
        "signature": "sig_test",
    }
    file_path = tmp_path / "token.json"
    file_path.write_text(json.dumps(token))
    return file_path


@pytest.fixture
def created_agent(tmp_state_dir: Path) -> AgentStateData:
    """Create an agent state on disk for testing."""
    job = JobSpec(persona_ref="test@test", instance_id="inst_test")
    result = create_agent_state(
        instance_id="inst_test",
        persona_name="test-persona",
        job=job,
    )
    assert isinstance(result, Success), f"Failed to create agent: {result.failure()}"
    return result.unwrap()


@pytest.fixture
def running_agent(tmp_state_dir: Path) -> AgentStateData:
    """Create a running agent state on disk for testing."""
    job = JobSpec(persona_ref="test@test", instance_id="inst_running")
    result = create_agent_state(
        instance_id="inst_running",
        persona_name="test-persona",
        job=job,
    )
    assert isinstance(result, Success), f"Failed to create agent: {result.failure()}"
    state = result.unwrap()

    # Transition to running
    update_result = write_state(
        state.agent_id,
        AgentStateData(
            **{**state.model_dump(), "state": AgentState.running, "pid": 12345}
        ),
        "start",
        {"from": "pending"},
    )
    assert isinstance(update_result, Success)
    return read_state(state.agent_id).unwrap()


@pytest.fixture
def completed_agent(tmp_state_dir: Path) -> AgentStateData:
    """Create a completed agent with result on disk for testing."""
    job = JobSpec(persona_ref="test@test", instance_id="inst_completed")
    result = create_agent_state(
        instance_id="inst_completed",
        persona_name="test-persona",
        job=job,
    )
    assert isinstance(result, Success), f"Failed to create agent: {result.failure()}"
    state = result.unwrap()

    # Transition to completed
    now = datetime.now(timezone.utc).isoformat()
    completed_state = AgentStateData(
        **{
            **state.model_dump(),
            "state": AgentState.completed,
            "updated_at": now,
        }
    )
    write_result_obj = write_state(state.agent_id, completed_state, "finish", {})
    assert isinstance(write_result_obj, Success)

    # Write result file
    agent_result = AgentResult(
        final_state=AgentState.completed,
        token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
        result_ref="results/test.json",
    )
    write_result_outcome = write_result(state.agent_id, agent_result)
    assert isinstance(write_result_outcome, Success)

    return read_state(state.agent_id).unwrap()


@pytest.fixture
def awaiting_approval_agent(tmp_state_dir: Path) -> AgentStateData:
    """Create an awaiting_approval agent state on disk for testing."""
    job = JobSpec(persona_ref="test@test", instance_id="inst_approval")
    result = create_agent_state(
        instance_id="inst_approval",
        persona_name="test-persona",
        job=job,
    )
    assert isinstance(result, Success), f"Failed to create agent: {result.failure()}"
    state = result.unwrap()

    # Transition to running then to awaiting_approval
    now = datetime.now(timezone.utc).isoformat()
    running_state = AgentStateData(
        **{
            **state.model_dump(),
            "state": AgentState.running,
            "updated_at": now,
        }
    )
    write_state(state.agent_id, running_state, "start", {})

    # Transition to awaiting_approval
    approval_state = AgentStateData(
        **{
            **running_state.model_dump(),
            "state": AgentState.awaiting_approval,
            "updated_at": now,
        }
    )
    write_state(state.agent_id, approval_state, "request_approval", {})

    return read_state(state.agent_id).unwrap()


@pytest.fixture
def suspended_agent(tmp_state_dir: Path) -> AgentStateData:
    """Create a suspended agent state on disk for testing."""
    job = JobSpec(persona_ref="test@test", instance_id="inst_suspended")
    result = create_agent_state(
        instance_id="inst_suspended",
        persona_name="test-persona",
        job=job,
    )
    assert isinstance(result, Success), f"Failed to create agent: {result.failure()}"
    state = result.unwrap()

    # Transition to running then to suspended
    now = datetime.now(timezone.utc).isoformat()
    running_state = AgentStateData(
        **{
            **state.model_dump(),
            "state": AgentState.running,
            "updated_at": now,
        }
    )
    write_state(state.agent_id, running_state, "start", {})

    # Transition to suspended
    suspended_state = AgentStateData(
        **{
            **running_state.model_dump(),
            "state": AgentState.suspended,
            "updated_at": now,
        }
    )
    write_state(state.agent_id, suspended_state, "suspend", {})

    return read_state(state.agent_id).unwrap()
    approval_state = AgentStateData(
        **{
            **state.model_dump(),
            "state": AgentState.awaiting_approval,
            "updated_at": now,
        }
    )
    write_result_obj = write_state(
        state.agent_id, approval_state, "request_approval", {}
    )
    assert isinstance(write_result_obj, Success)

    return read_state(state.agent_id).unwrap()


# =============================================================================
# Help Tests
# =============================================================================


class TestCliHelp:
    """Tests for CLI help and command discovery."""

    def test_main_help(self, cli_runner: CliRunner) -> None:
        """Main command shows help with all subcommands."""
        result = cli_runner.invoke(main, ["--help"])
        assert result.exit_code == 0
        assert "anima" in result.output
        assert "run" in result.output
        assert "spawn" in result.output
        assert "list" in result.output
        assert "status" in result.output
        assert "collect" in result.output
        assert "kill" in result.output
        assert "approve" in result.output
        assert "reject" in result.output
        assert "resume" in result.output
        assert "monitor" in result.output
        assert "serve" in result.output
        assert "talk" in result.output

    def test_main_help_groups_and_command_membership(
        self, cli_runner: CliRunner
    ) -> None:
        """Spec source: help-groups.add-test-for-grouped-help-output."""
        result = cli_runner.invoke(main, ["--help"])

        assert result.exit_code == 0
        output = result.output

        headers = [
            "Launch Commands:",
            "Observe Commands:",
            "Control Commands:",
            "Monitor Commands:",
            "Cleanup Commands:",
        ]
        positions = [output.index(header) for header in headers]
        assert positions == sorted(positions)

        expected_group_commands = {
            "Launch Commands:": ("run", "spawn"),
            "Observe Commands:": ("list", "status", "inspect", "logs", "tree"),
            "Control Commands:": ("approve", "reject", "resume", "kill"),
            "Monitor Commands:": ("monitor", "serve", "talk"),
            "Cleanup Commands:": ("rm", "prune"),
        }

        for header, commands in expected_group_commands.items():
            section_match = re.search(
                rf"{re.escape(header)}\n((?:\s{{2}}\S.*\n)+)",
                output,
                flags=re.MULTILINE,
            )
            assert section_match is not None
            section_body = section_match.group(1)

            for command_name in commands:
                assert re.search(
                    rf"^\s{{2}}{re.escape(command_name)}\s+",
                    section_body,
                    flags=re.MULTILINE,
                )

    def test_run_help(self, cli_runner: CliRunner) -> None:
        """Run command shows help with options."""
        result = cli_runner.invoke(main, ["run", "--help"])
        assert result.exit_code == 0
        assert "Run a one-off" in result.output
        assert "--persona-file" in result.output
        assert "--job-file" in result.output
        assert "--model" in result.output
        assert "--json" in result.output

    def test_spawn_help(self, cli_runner: CliRunner) -> None:
        """Spawn command shows help with options."""
        result = cli_runner.invoke(main, ["spawn", "--help"])
        assert result.exit_code == 0
        assert "Spawn a background agent" in result.output
        assert "--persona-file" in result.output
        assert "--lifecycle" in result.output
        assert "--json" in result.output

    def test_run_help_is_byte_identical(self, cli_runner: CliRunner) -> None:
        """Spec source: cli-chat-command.extract-agent-options-decorator."""
        result = cli_runner.invoke(main, ["run", "--help"])
        assert result.exit_code == 0
        assert result.output == EXPECTED_RUN_HELP

    def test_spawn_help_is_byte_identical(self, cli_runner: CliRunner) -> None:
        """Spec source: cli-chat-command.extract-agent-options-decorator."""
        result = cli_runner.invoke(main, ["spawn", "--help"])
        assert result.exit_code == 0
        assert result.output == EXPECTED_SPAWN_HELP

    def test_list_help(self, cli_runner: CliRunner) -> None:
        """List command shows help with options."""
        result = cli_runner.invoke(main, ["list", "--help"])
        assert result.exit_code == 0
        assert "List all tracked agents" in result.output
        assert "--state" in result.output
        assert "--json" in result.output

    def test_status_help(self, cli_runner: CliRunner) -> None:
        """Status command shows help with options."""
        result = cli_runner.invoke(main, ["status", "--help"])
        assert result.exit_code == 0
        assert "Show details for a single agent" in result.output
        assert "agent_id" in result.output
        assert "--json" in result.output

    def test_collect_help(self, cli_runner: CliRunner) -> None:
        """Collect command shows help with options."""
        result = cli_runner.invoke(main, ["collect", "--help"])
        assert result.exit_code == 0
        assert "Harvest result" in result.output
        assert "--wait" in result.output
        assert "--timeout" in result.output
        assert "--json" in result.output

    def test_kill_help(self, cli_runner: CliRunner) -> None:
        """Kill command shows help with options."""
        result = cli_runner.invoke(main, ["kill", "--help"])
        assert result.exit_code == 0
        assert "Kill a running agent" in result.output
        assert "AGENT_ID" in result.output  # Click uses uppercase for args
        assert "--json" in result.output

    def test_approve_help(self, cli_runner: CliRunner) -> None:
        """Approve command shows help with options."""
        result = cli_runner.invoke(main, ["approve", "--help"])
        assert result.exit_code == 0
        assert "Approve an agent" in result.output
        assert "approval request" in result.output
        assert "--note" in result.output
        assert "--json" in result.output

    def test_reject_help(self, cli_runner: CliRunner) -> None:
        """Reject command shows help with options."""
        result = cli_runner.invoke(main, ["reject", "--help"])
        assert result.exit_code == 0
        assert "Reject an agent" in result.output
        assert "approval request" in result.output
        assert "--reason" in result.output
        assert "--json" in result.output

    def test_resume_help(self, cli_runner: CliRunner) -> None:
        """Resume command shows help with options."""
        result = cli_runner.invoke(main, ["resume", "--help"])
        assert result.exit_code == 0
        assert "Resume a suspended agent" in result.output
        assert "--json" in result.output

    def test_monitor_help(self, cli_runner: CliRunner) -> None:
        """Monitor command shows help with options."""
        result = cli_runner.invoke(main, ["monitor", "--help"])
        assert result.exit_code == 0
        assert "TUI dashboard" in result.output
        assert "--refresh" in result.output

    def test_serve_help(self, cli_runner: CliRunner) -> None:
        """Serve command shows help with options."""
        result = cli_runner.invoke(main, ["serve", "--help"])
        assert result.exit_code == 0
        assert "MCP server" in result.output
        assert "--port" in result.output
        assert "--host" in result.output

    def test_talk_help(self, cli_runner: CliRunner) -> None:
        """Talk command shows help with options."""
        result = cli_runner.invoke(main, ["talk", "--help"])
        assert result.exit_code == 0
        assert "interactive session" in result.output
        assert "AGENT_ID" in result.output  # Click uses uppercase for args
        assert "--json" in result.output


# =============================================================================
# Run Command Tests
# =============================================================================


class TestCliRun:
    """Tests for 'anima run' command."""

    def test_run_missing_persona_file(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run fails with exit code 2 when persona file is missing."""
        result = cli_runner.invoke(
            main,
            ["run", "test task", "--persona-file", "/nonexistent/persona.json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Missing file should cause error
        assert result.exit_code != 0

    def test_run_no_persona_no_model_fails(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run without persona/model shows tutorial error."""
        result = cli_runner.invoke(
            main,
            ["run", "test task"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Should fail with model guidance (system_prompt is optional)
        assert result.exit_code != 0
        assert "no model configured" in result.output.lower()

    def test_run_real_cli_persists_state_and_result_files(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
        persona_file: Path,
    ) -> None:
        """Run path writes real state/result artifacts via CLI -> handler -> persistence."""
        instance_id = "inst_real_cli_run"
        config_file = tmp_path / "config.toml"
        config_file.write_text("")
        short_state_path = Path(f"/tmp/anima_cli_run_{os.getpid()}_{time.time_ns()}")
        short_state_path.symlink_to(tmp_state_dir, target_is_directory=True)

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Produce persisted artifacts",
                "--persona-file",
                str(persona_file),
                "--model",
                "test",
                "--instance-id",
                instance_id,
                "--json",
                "--no-rm",
            ],
            env={
                "ANIMA_STATE": str(short_state_path),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0, (
            f"output={result.output!r} exception={result.exception!r}"
        )
        output = json.loads(result.output.strip())
        assert output["instance_id"] == instance_id
        assert output["final_state"] == "completed"

        agent_dir = get_agent_dir(instance_id)
        state_file = agent_dir / "state.json"
        result_file = agent_dir / "result.json"
        log_file = agent_dir / "log.jsonl"

        assert state_file.exists()
        assert result_file.exists()
        assert log_file.exists()

        state_data = json.loads(state_file.read_text())
        result_data = json.loads(result_file.read_text())
        log_lines = [
            json.loads(line)
            for line in log_file.read_text().splitlines()
            if line.strip()
        ]

        assert state_data["state"] == "completed"
        assert result_data["final_state"] == "completed"
        assert any(entry.get("event") == "start" for entry in log_lines)
        assert any(
            entry.get("details", {}).get("state") == "completed"
            or entry.get("details", {}).get("to") == "completed"
            for entry in log_lines
        )

        short_state_path.unlink()

    @patch("anima.shell.agent.AgentRunner")
    @patch("anima.shell.cli_commands.run_spawn.DefaultLaunchService.launch")
    def test_run_regression_path_uses_shared_launch_service(
        self,
        mock_launch_service: MagicMock,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Regression: run path delegates launch resolution to shared service once."""
        fixture = make_run_spawn_resolution_regression_fixture()
        contract_fixture = make_consumer_conformance_fixture(
            instance_id="inst_regression_run"
        )
        mock_launch_service.return_value = Success(
            LaunchResult(
                instance_id="inst_regression_run",
                trace_id="tr_regression_run",
                resolved_contract=contract_fixture.contract,
                initial_state=AgentStateData(
                    instance_id="inst_regression_run",
                    trace_id="tr_regression_run",
                    persona_name="worker",
                    persona_ref="personas/worker.json",
                    model=contract_fixture.contract.runtime.effective_model,
                ),
            )
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "regression run task",
                "--persona-file",
                str(persona_file),
                "--instance-id",
                "inst_regression_run",
                "--json",
                "--no-rm",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        assert mock_launch_service.call_count == fixture.expected_resolution_calls

    @patch("anima.shell.agent.AgentRunner")
    def test_run_success_json_output(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Run creates agent and outputs JSON on success."""
        # Mock AgentRunner to return success
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        # Should exit 0 for completed state
        assert result.exit_code == 0

        # Parse JSON output
        output = json.loads(result.output.strip())
        assert "agent_id" in output
        assert "final_state" in output
        assert output["final_state"] == "completed"
        assert "token_usage" in output

    @patch("anima.shell.agent.AgentRunner")
    def test_run_shared_agent_options_smoke(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spec source: cli-chat-command.extract-agent-options-decorator."""
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--model",
                "gpt-test",
                "--context-mode",
                "shared",
                "--max-turns",
                "5",
                "--max-tokens",
                "50",
                "--completion-policy",
                "explicit",
                "--instance-id",
                "inst_smoke_123",
                "--sandbox",
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_runner_class.call_args
        assert kwargs["instance_id"] == "inst_smoke_123"
        assert kwargs["max_turns"] == 5
        assert kwargs["model_override"] == "gpt-test"

    @patch("anima.shell.agent.AgentRunner")
    def test_run_failed_state(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Run exits with code 1 when agent fails."""
        # Mock AgentRunner to return failed state
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.failed,
                    token_usage=TokenUsage(prompt_tokens=10, completion_tokens=5),
                    error="Test error",
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        # Should exit 1 for failed state
        assert result.exit_code == 1

        output = json.loads(result.output.strip())
        assert output["final_state"] == "failed"
        assert output["error"] == "Test error"

        result_file = get_agent_dir(output["agent_id"]) / "result.json"
        assert result_file.exists()
        persisted = json.loads(result_file.read_text())
        assert persisted["final_state"] == "failed"
        assert persisted["error"] == "Test error"

    @patch("anima.shell.agent.AgentRunner")
    def test_run_passes_capability_token_to_agent_runner(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
        token_file: Path,
    ) -> None:
        """Run loads --token-file and passes CapabilityToken to AgentRunner."""
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--token-file",
                str(token_file),
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_runner_class.call_args
        assert "capability_token" in kwargs
        token = kwargs["capability_token"]
        assert token is not None
        assert token.token_id == "tok_test123"
        assert token.tools_profile == "default"
        assert not hasattr(token, "budget")

    @patch("anima.shell.agent.AgentRunner")
    def test_run_rejects_legacy_capability_token_budget_field(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
        tmp_path: Path,
    ) -> None:
        """Run rejects legacy CapabilityToken payloads containing budget."""
        legacy_token_file = tmp_path / "token-legacy-budget.json"
        legacy_token_file.write_text(
            json.dumps(
                {
                    "token_id": "tok_legacy123",
                    "tools_profile": "default",
                    "budget": 1000,
                    "issued_at": "2026-03-01T10:00:00Z",
                    "expires_at": "2026-03-01T11:00:00Z",
                    "signature": "sig_test",
                }
            )
        )

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--token-file",
                str(legacy_token_file),
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 1
        assert "Error loading token" in result.output
        mock_runner_class.assert_not_called()

    @patch("anima.shell.cli.parse_mcp_servers", side_effect=ValueError("bad mcp"))
    def test_run_reports_invalid_mcp_value_cleanly(
        self,
        _mock_parse_mcp_servers: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Run reports malformed --mcp as user-facing error."""
        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--mcp",
                "unterminated 'quote",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 1
        assert "Invalid --mcp value" in result.output

    def test_run_rejects_invalid_llm_base_url(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Run rejects malformed --llm-base-url with clear message."""
        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--llm-base-url",
                "not-a-url",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 1
        assert "Invalid --llm-base-url" in result.output

    # =============================================================================
    # Sandbox Flag Tests
    # =============================================================================

    def test_run_sandbox_flags_visible_in_help(self, cli_runner: CliRunner) -> None:
        """Spec source: sandbox-srt.cli-flags. --sandbox, --no-sandbox, --sandbox-allow visible in help."""
        result = cli_runner.invoke(main, ["run", "--help"])
        assert result.exit_code == 0
        assert "--sandbox" in result.output
        assert "--no-sandbox" in result.output
        assert "--sandbox-allow" in result.output

    def test_spawn_sandbox_flags_visible_in_help(self, cli_runner: CliRunner) -> None:
        """Spec source: sandbox-srt.cli-flags. --sandbox, --no-sandbox, --sandbox-allow visible in help."""
        result = cli_runner.invoke(main, ["spawn", "--help"])
        assert result.exit_code == 0
        assert "--sandbox" in result.output
        assert "--no-sandbox" in result.output
        assert "--sandbox-allow" in result.output

    def test_run_rejects_both_sandbox_and_no_sandbox(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spec source: sandbox-srt.cli-flags. --sandbox + --no-sandbox = error."""
        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--sandbox",
                "--no-sandbox",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )
        assert result.exit_code == 1
        assert "Cannot use both --sandbox and --no-sandbox" in result.output

    @patch("anima.shell.cli.subprocess.Popen")
    @patch("anima.shell.cli.load_persona")
    def test_spawn_rejects_both_sandbox_and_no_sandbox(
        self,
        mock_load_persona: MagicMock,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spec source: sandbox-srt.cli-flags. --sandbox + --no-sandbox = error."""
        mock_load_persona.return_value = PersonaSpec(
            id="test-persona",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
        )
        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--sandbox",
                "--no-sandbox",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )
        assert result.exit_code == 1
        assert "Cannot use both --sandbox and --no-sandbox" in result.output

    @patch("anima.shell.agent.AgentRunner")
    def test_run_sandbox_allow_implies_sandbox(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spec source: sandbox-srt.cli-flags. --sandbox-allow without --sandbox implies --sandbox."""
        from anima.core.sandbox import SandboxPolicy

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--sandbox-allow",
                "fs:/tmp",
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # SandboxPolicy should be enabled with allow list
        _, kwargs = mock_runner_class.call_args
        assert "sandbox_policy" in kwargs
        policy = kwargs["sandbox_policy"]
        assert isinstance(policy, SandboxPolicy)
        assert policy.enabled is True
        assert "/tmp" in policy.allow_fs

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_auto_loads_from_persona(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = {
            "id": "test-tools-map",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write", "shell": "read_write"},
        }
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(json.dumps(persona))

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            'args = ["--root", "/tmp"]\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            ["run", "Test task", "--persona-file", str(persona_file)],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Verify tools from profile were loaded
        _, kwargs = mock_runner_class.call_args
        assert "mcp_servers" in kwargs
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        # Should have 2 MCP servers from profile
        assert len(mcp_servers) == 2

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_single_filesystem_preset_loads(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        persona = {
            "id": "test-tools-map",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write"},
        }
        persona_file = tmp_path / "persona-tools-single.json"
        persona_file.write_text(json.dumps(persona))

        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            ["run", "Test task", "--persona-file", str(persona_file)],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_runner_class.call_args
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        assert len(mcp_servers) == 1

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_additive_with_cli_tools(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = {
            "id": "test-tools-map",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write"},
        }
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(json.dumps(persona))

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            'args = ["--root", "/tmp"]\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--tools",
                "shell",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Verify both profile (filesystem) and CLI (shell) tools are present
        _, kwargs = mock_runner_class.call_args
        assert "mcp_servers" in kwargs
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        # Should have 2 MCP servers: filesystem from profile + shell from CLI
        assert len(mcp_servers) == 2

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_deduplicates(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = {
            "id": "test-tools-map",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write", "shell": "read_write"},
        }
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(json.dumps(persona))

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            'args = ["--root", "/tmp"]\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--tools",
                "filesystem",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Verify deduplication: filesystem in both profile and CLI should not duplicate
        _, kwargs = mock_runner_class.call_args
        assert "mcp_servers" in kwargs
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        # Should have 2 MCP servers: filesystem+shell from profile, deduped with CLI
        assert len(mcp_servers) == 2

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_skip_with_capability_token(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
        token_file: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = {
            "id": "test-tools-map",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write", "shell": "read_write"},
        }
        persona_file = tmp_path / "persona-tools-token.json"
        persona_file.write_text(json.dumps(persona))

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--token-file",
                str(token_file),
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Scoped compatibility exception: token.tools_profile is passed through,
        # but persona.tools local preset resolution is skipped.
        _, kwargs = mock_runner_class.call_args
        assert "mcp_servers" in kwargs
        mcp_servers = kwargs["mcp_servers"]
        # Should be None or empty - token takes precedence
        assert mcp_servers is None or len(mcp_servers) == 0

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_warns_unknown_preset(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with unknown tools
        persona = {
            "id": "test-tools-profile",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"unknown": "read_write", "filesystem": "read_write"},
        }
        persona_file = tmp_path / "persona-tools-unknown.json"
        persona_file.write_text(json.dumps(persona))

        # Create config with only one preset
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            ["run", "Test task", "--persona-file", str(persona_file)],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Should warn about unknown preset
        assert "Warning" in result.output or "unknown" in result.output.lower()
        # Should still load 'filesystem' preset
        _, kwargs = mock_runner_class.call_args
        assert "mcp_servers" in kwargs
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        assert len(mcp_servers) == 1

    @patch("anima.shell.agent.AgentRunner")
    def test_run_tools_map_additive_with_explicit_mcp(
        self,
        mock_runner_class: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        persona = {
            "id": "test-tools-profile",
            "prompt": "You are a test assistant.",
            "model": "test-model",
            "spec_version": "0.1.0",
            "tools": {"filesystem": "read_write"},
        }
        persona_file = tmp_path / "persona-tools-mcp.json"
        persona_file.write_text(json.dumps(persona))

        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
        )

        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=1, completion_tokens=1),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--mcp",
                "https://example.com/mcp",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_runner_class.call_args
        mcp_servers = kwargs["mcp_servers"]
        assert mcp_servers is not None
        assert len(mcp_servers) == 2


# =============================================================================
# Spawn Command Tests
# =============================================================================


class TestCliSpawn:
    """Tests for 'anima spawn' command."""

    def test_spawn_daemon_lifecycle_is_preserved_when_completion_policy_omitted(
        self,
    ) -> None:
        """Explicit daemon lifecycle must not be silently downgraded."""

        lifecycle, completion_policy = _resolve_spawn_lifecycle_and_completion_policy(
            "daemon",
            None,
        )

        assert lifecycle.value == "daemon"
        assert completion_policy == "auto"

    @patch("anima.shell.cli_commands.run_spawn.transition_state")
    @patch("anima.shell.cli_commands.run_spawn.write_agent_pid")
    @patch("anima.shell.cli_commands.run_spawn._launch_spawn_subprocess_or_exit")
    @patch("anima.shell.cli_commands.run_spawn.DefaultLaunchService.launch")
    def test_spawn_regression_fixture_resolves_contract_once(
        self,
        mock_launch_service: MagicMock,
        mock_spawn_subprocess: MagicMock,
        mock_write_pid: MagicMock,
        mock_transition_state: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Regression: spawn path delegates launch resolution to shared service once."""
        fixture = make_run_spawn_resolution_regression_fixture()
        contract_fixture = make_consumer_conformance_fixture(
            instance_id="inst_regression_spawn"
        )
        mock_launch_service.return_value = Success(
            LaunchResult(
                instance_id="inst_regression_spawn",
                trace_id="tr_regression_spawn",
                resolved_contract=contract_fixture.contract,
                initial_state=AgentStateData(
                    instance_id="inst_regression_spawn",
                    trace_id="tr_regression_spawn",
                    persona_name="worker",
                    persona_ref="personas/worker.json",
                    model=contract_fixture.contract.runtime.effective_model,
                ),
            )
        )
        mock_proc = MagicMock()
        mock_proc.pid = 10001
        mock_spawn_subprocess.return_value = mock_proc
        mock_write_pid.return_value = Success(None)
        mock_transition_state.return_value = Success(None)

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "regression spawn task",
                "--persona-file",
                str(persona_file),
                "--instance-id",
                "inst_regression_spawn",
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        assert mock_launch_service.call_count == fixture.expected_resolution_calls

    def test_spawn_missing_persona_file(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Spawn fails when persona file is missing (--persona-file is required)."""
        result = cli_runner.invoke(
            main,
            ["spawn", "test task", "--persona-file", "/nonexistent/persona.json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Should fail because persona file is required
        assert result.exit_code != 0
        # Check the actual behavior based on CLI implementation

    def test_spawn_real_cli_writes_state_and_stages_runner_command(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
        job_file: Path,
    ) -> None:
        """Spawn path persists state and PID while faking only subprocess boundary."""

        class _FakeProcess:
            def __init__(self, pid: int) -> None:
                self.pid = pid

        instance_id = "inst_real_cli_spawn"
        with patch(
            "anima.shell.cli_commands.run_spawn.subprocess.Popen",
            return_value=_FakeProcess(pid=67890),
        ) as mock_popen:
            result = cli_runner.invoke(
                main,
                [
                    "spawn",
                    "Background task",
                    "--persona-file",
                    str(persona_file),
                    "--job-file",
                    str(job_file),
                    "--model",
                    "test",
                    "--instance-id",
                    instance_id,
                    "--json",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["instance_id"] == instance_id
        assert output["state"] == "pending"
        assert output["pid"] == 67890

        popen_cmd = mock_popen.call_args.args[0]
        assert "--persona-file" in popen_cmd
        assert str(persona_file) in popen_cmd
        assert "--job-file" in popen_cmd
        assert str(job_file) in popen_cmd

        state_file = get_agent_dir(instance_id) / "state.json"
        log_file = get_agent_dir(instance_id) / "log.jsonl"
        state_data = json.loads(state_file.read_text())
        log_lines = [
            json.loads(line)
            for line in log_file.read_text().splitlines()
            if line.strip()
        ]
        assert state_data["state"] == "running"
        assert state_data["pid"] == 67890
        assert any(entry.get("event") == "start" for entry in log_lines)

    def test_spawn_real_cli_preserves_explicit_daemon_lifecycle(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """CLI spawn keeps daemon lifecycle in persisted state and output."""

        class _FakeProcess:
            def __init__(self, pid: int) -> None:
                self.pid = pid

        instance_id = "inst_spawn_daemon"
        with patch(
            "anima.shell.cli_commands.run_spawn.subprocess.Popen",
            return_value=_FakeProcess(pid=24680),
        ):
            result = cli_runner.invoke(
                main,
                [
                    "spawn",
                    "Background task",
                    "--persona-file",
                    str(persona_file),
                    "--instance-id",
                    instance_id,
                    "--lifecycle",
                    "daemon",
                    "--json",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["instance_id"] == instance_id
        assert output["state"] == "pending"
        assert output["pid"] == 24680

        state_file = get_agent_dir(instance_id) / "state.json"
        state_data = json.loads(state_file.read_text())
        assert state_data["lifecycle"] == "daemon"

    @patch("anima.shell.cli.subprocess.Popen")
    @patch("anima.shell.cli.load_persona")
    def test_spawn_creates_agent(
        self,
        mock_load_persona: MagicMock,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spawn creates agent state and launches subprocess."""
        # Setup mocks
        mock_load_persona.return_value = PersonaSpec(
            id="test-persona",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
        )
        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            ["spawn", "Test task", "--persona-file", str(persona_file)],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Should have called Popen to spawn the subprocess
        mock_popen.assert_called_once()

    @patch("anima.shell.cli.subprocess.Popen")
    @patch("anima.shell.cli.load_persona")
    def test_spawn_json_output(
        self,
        mock_load_persona: MagicMock,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spawn outputs JSON when --json flag is set."""
        mock_load_persona.return_value = PersonaSpec(
            id="test-persona",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
        )
        mock_proc = MagicMock()
        mock_proc.pid = 54321
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            ["spawn", "Test task", "--persona-file", str(persona_file), "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert "instance_id" in output
        assert output["state"] == "pending"
        assert "pid" in output

    @patch("anima.shell.cli.subprocess.Popen")
    @patch("anima.shell.cli.load_persona")
    def test_spawn_shared_agent_options_smoke(
        self,
        mock_load_persona: MagicMock,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spec source: cli-chat-command.extract-agent-options-decorator."""
        mock_load_persona.return_value = PersonaSpec(
            id="test-persona",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
        )
        mock_proc = MagicMock()
        mock_proc.pid = 54321
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--llm-base-url",
                "https://example.com/v1",
                "--sandbox-allow",
                "fs:/tmp",
                "--json",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_popen.call_args
        cmd = kwargs.get("args")
        if cmd is None:
            cmd = mock_popen.call_args.args[0]

        assert "--llm-base-url" in cmd
        assert "https://example.com/v1" in cmd
        assert "--sandbox-allow" in cmd
        assert "fs:/tmp" in cmd

    @patch("anima.shell.cli.subprocess.Popen")
    @patch("anima.shell.cli.load_persona")
    def test_spawn_passes_token_file_to_agent_runner(
        self,
        mock_load_persona: MagicMock,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
        token_file: Path,
    ) -> None:
        """Spawn validates --token-file and forwards it to agent_runner.py."""
        mock_load_persona.return_value = PersonaSpec(
            id="test-persona",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
        )
        mock_proc = MagicMock()
        mock_proc.pid = 10101
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--token-file",
                str(token_file),
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        _, kwargs = mock_popen.call_args
        cmd = kwargs.get("args")
        if cmd is None:
            cmd = mock_popen.call_args.args[0]
        assert "--token-file" in cmd
        token_idx = cmd.index("--token-file")
        assert cmd[token_idx + 1] == str(token_file)

    @patch("anima.shell.cli.parse_mcp_servers", side_effect=ValueError("bad mcp"))
    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_reports_invalid_mcp_value_cleanly(
        self,
        mock_popen: MagicMock,
        _mock_parse_mcp_servers: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spawn reports malformed --mcp and does not launch subprocess."""
        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--mcp",
                "unterminated 'quote",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 1
        assert "Invalid --mcp value" in result.output
        mock_popen.assert_not_called()

    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_rejects_invalid_llm_base_url(
        self,
        mock_popen: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        persona_file: Path,
    ) -> None:
        """Spawn rejects malformed --llm-base-url before subprocess launch."""
        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--llm-base-url",
                "not-a-url",
            ],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            catch_exceptions=False,
        )

        assert result.exit_code == 1
        assert "Invalid --llm-base-url" in result.output
        mock_popen.assert_not_called()

    @patch("anima.shell.cli.load_persona")
    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_tools_map_auto_loads_from_persona(
        self,
        mock_popen: MagicMock,
        mock_load_persona: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = PersonaSpec(
            id="test-tools-profile",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
            tools={"filesystem": "read_write", "shell": "read_write"},
        )
        mock_load_persona.return_value = persona

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            'args = ["--root", "/tmp"]\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        # Create a temp persona file for the test
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(
            json.dumps(
                {
                    "id": "test-tools-map",
                    "prompt": "You are a test assistant.",
                    "model": "test-model",
                    "spec_version": "0.1.0",
                    "tools": {"filesystem": "read_write", "shell": "read_write"},
                }
            )
        )

        result = cli_runner.invoke(
            main,
            ["spawn", "Test task", "--persona-file", str(persona_file)],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Verify --mcp args passed to subprocess contain resolved tools
        call_args = mock_popen.call_args
        cmd = call_args[1]["args"] if "args" in call_args[1] else call_args[0][0]
        # Should have --mcp flags for filesystem and shell from profile
        mcp_args = [
            cmd[i + 1]
            for i, arg in enumerate(cmd)
            if arg == "--mcp" and i + 1 < len(cmd)
        ]
        assert len(mcp_args) == 2
        assert "mcp-filesystem" in " ".join(mcp_args)
        assert "mcp-shell" in " ".join(mcp_args)

    @patch("anima.shell.cli.load_persona")
    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_tools_map_additive_with_cli(
        self,
        mock_popen: MagicMock,
        mock_load_persona: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = PersonaSpec(
            id="test-tools-profile",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
            tools={"filesystem": "read_write"},
        )
        mock_load_persona.return_value = persona

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        # Create a temp persona file for the test
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(
            json.dumps(
                {
                    "id": "test-tools-map",
                    "prompt": "You are a test assistant.",
                    "model": "test-model",
                    "spec_version": "0.1.0",
                    "tools": {"filesystem": "read_write"},
                }
            )
        )

        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--tools",
                "shell",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Verify both profile (filesystem) and CLI (shell) tools passed
        call_args = mock_popen.call_args
        cmd = call_args[1]["args"] if "args" in call_args[1] else call_args[0][0]
        mcp_args = [
            cmd[i + 1]
            for i, arg in enumerate(cmd)
            if arg == "--mcp" and i + 1 < len(cmd)
        ]
        assert len(mcp_args) == 2

    @patch("anima.shell.cli.load_persona")
    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_tools_map_skipped_with_token(
        self,
        mock_popen: MagicMock,
        mock_load_persona: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
        token_file: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        # Create persona with tools
        persona = PersonaSpec(
            id="test-tools-profile",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
            tools={"filesystem": "read_write", "shell": "read_write"},
        )
        mock_load_persona.return_value = persona

        # Create config with tool presets
        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
            "\n"
            "[tools.shell]\n"
            'command = "mcp-shell"\n'
        )

        # Create a temp persona file for the test
        persona_file = tmp_path / "persona-tools.json"
        persona_file.write_text(
            json.dumps(
                {
                    "id": "test-tools-profile",
                    "prompt": "You are a test assistant.",
                    "model": "test-model",
                    "spec_version": "0.1.0",
                    "tools": {"filesystem": "read_write", "shell": "read_write"},
                }
            )
        )

        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--token-file",
                str(token_file),
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        # Scoped compatibility exception: token.tools_profile remains valid,
        # while persona.tools local preset resolution is skipped.
        call_args = mock_popen.call_args
        cmd = call_args[1]["args"] if "args" in call_args[1] else call_args[0][0]
        # Only --token-file should be present, no --mcp from profile
        mcp_args = [
            cmd[i + 1]
            for i, arg in enumerate(cmd)
            if arg == "--mcp" and i + 1 < len(cmd)
        ]
        assert len(mcp_args) == 0

    @patch("anima.shell.cli.load_persona")
    @patch("anima.shell.cli.subprocess.Popen")
    def test_spawn_tools_map_additive_with_explicit_mcp(
        self,
        mock_popen: MagicMock,
        mock_load_persona: MagicMock,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        tmp_path: Path,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        persona = PersonaSpec(
            id="test-tools-profile",
            description="A test persona.",
            prompt="You are a test assistant.",
            model="test-model",
            tools={"filesystem": "read_write"},
        )
        mock_load_persona.return_value = persona

        config_dir = tmp_path / ".anima"
        config_dir.mkdir(exist_ok=True)
        config_file = config_dir / "config.toml"
        config_file.write_text(
            "[defaults]\n"
            'model = "test-model"\n'
            "\n"
            "[tools.filesystem]\n"
            'command = "mcp-filesystem"\n'
        )

        persona_file = tmp_path / "persona-tools-mcp.json"
        persona_file.write_text(
            json.dumps(
                {
                    "id": "test-tools-profile",
                    "prompt": "You are a test assistant.",
                    "model": "test-model",
                    "spec_version": "0.1.0",
                    "tools": {"filesystem": "read_write"},
                }
            )
        )

        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        result = cli_runner.invoke(
            main,
            [
                "spawn",
                "Test task",
                "--persona-file",
                str(persona_file),
                "--mcp",
                "https://example.com/mcp",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
                "ANIMA_CONFIG": str(config_file),
            },
            catch_exceptions=False,
        )

        assert result.exit_code == 0
        call_args = mock_popen.call_args
        cmd = call_args[1]["args"] if "args" in call_args[1] else call_args[0][0]
        mcp_args = [
            cmd[i + 1]
            for i, arg in enumerate(cmd)
            if arg == "--mcp" and i + 1 < len(cmd)
        ]
        assert len(mcp_args) == 2


# =============================================================================
# List Command Tests
# =============================================================================


class TestCliList:
    """Tests for 'anima list' command."""

    def test_list_empty(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """List shows no agents when empty."""
        result = cli_runner.invoke(
            main,
            ["list"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "No agents found" in result.output

    def test_list_shows_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """List shows created agents in table format."""
        result = cli_runner.invoke(
            main,
            ["list"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Agent ID should be displayed (truncated to 8 chars)
        assert created_agent.agent_id[:8] in result.output
        assert "pending" in result.output.lower() or "PENDING" in result.output

    def test_list_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """List outputs JSON array when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["list", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert isinstance(output, list)
        assert len(output) == 1
        assert output[0]["agent_id"] == created_agent.agent_id

    def test_list_filter_by_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """List filters by state when --state flag is used."""
        result = cli_runner.invoke(
            main,
            ["list", "--state", "running"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "running" in result.output.lower()

    def test_list_invalid_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """List fails with error for invalid state filter."""
        result = cli_runner.invoke(
            main,
            ["list", "--state", "invalid_state"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "Invalid state" in result.output

    def test_list_multiple_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """List shows multiple agents with correct info."""
        # Create multiple agents
        job1 = JobSpec(persona_ref="test@test", instance_id="inst_1")
        job2 = JobSpec(persona_ref="test@test", instance_id="inst_2")
        create_agent_state("inst_1", "persona-1", job1)
        create_agent_state("inst_2", "persona-2", job2)

        result = cli_runner.invoke(
            main,
            ["list", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert len(output) == 2


# =============================================================================
# Status Command Tests
# =============================================================================


class TestCliStatus:
    """Tests for 'anima status' command."""

    def test_status_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Status exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["status", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        assert "not found" in result.output.lower()

    def test_status_agent_not_found_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Status outputs JSON error when agent not found."""
        result = cli_runner.invoke(
            main,
            ["status", "nonexistent_agent", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_FOUND"
        assert output["error"]["numeric_code"] == 300

    def test_status_success(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Status shows agent details in human-readable format."""
        result = cli_runner.invoke(
            main,
            ["status", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert running_agent.agent_id in result.output
        assert "running" in result.output.lower()

    def test_status_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Status outputs JSON when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["status", created_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["agent_id"] == created_agent.agent_id
        assert output["state"] == "pending"

    def test_status_json_matches_persisted_state_file(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Status reflects persisted state.json values, not mocked call assertions."""
        state_file = get_agent_dir(running_agent.agent_id) / "state.json"
        persisted = json.loads(state_file.read_text())

        result = cli_runner.invoke(
            main,
            ["status", running_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["agent_id"] == persisted["agent_id"]
        assert output["state"] == persisted["state"]
        assert output["pid"] == persisted["pid"]

    def test_status_prefix_match(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Status finds agent by ID prefix."""
        # Use first 8 characters as prefix
        prefix = created_agent.agent_id[:8]
        result = cli_runner.invoke(
            main,
            ["status", prefix],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert created_agent.agent_id in result.output


# =============================================================================
# Collect Command Tests
# =============================================================================


class TestCliCollect:
    """Tests for 'anima collect' command."""

    def test_collect_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Collect exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["collect", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_collect_non_terminal_without_wait(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Collect exits with code 1 for non-terminal state without --wait."""
        result = cli_runner.invoke(
            main,
            ["collect", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "still" in result.output.lower()

    def test_collect_non_terminal_json(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        running_agent: AgentStateData,
    ) -> None:
        """Collect outputs JSON error for non-terminal state."""
        result = cli_runner.invoke(
            main,
            ["collect", running_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        output = json.loads(result.output.strip())
        # Section G error envelope format
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_TERMINAL"
        assert output["error"]["numeric_code"] == 315
        assert "still" in output["error"]["message"].lower()
        assert output["error"]["details"]["instance_id"] == running_agent.instance_id

    def test_collect_completed_agent(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Collect returns result for completed agent."""
        result = cli_runner.invoke(
            main,
            ["collect", completed_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "completed" in result.output.lower()

    def test_collect_completed_agent_json(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Collect outputs JSON result for completed agent."""
        result = cli_runner.invoke(
            main,
            ["collect", completed_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["final_state"] == "completed"
        assert "token_usage" in output
        assert output["token_usage"]["total_tokens"] == 150

    def test_collect_json_uses_persisted_result_file(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Collect response fields mirror persisted result.json payload."""
        result_file = get_agent_dir(completed_agent.agent_id) / "result.json"
        persisted_result = json.loads(result_file.read_text())

        result = cli_runner.invoke(
            main,
            ["collect", completed_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["final_state"] == persisted_result["final_state"]
        assert (
            output["token_usage"]["total_tokens"]
            == (persisted_result["token_usage"]["total_tokens"])
        )

    def test_collect_prefix_match(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Collect finds agent by ID prefix."""
        prefix = completed_agent.agent_id[:8]
        result = cli_runner.invoke(
            main,
            ["collect", prefix, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["instance_id"] == completed_agent.instance_id


# =============================================================================
# Kill Command Tests
# =============================================================================


class TestCliKill:
    """Tests for 'anima kill' command."""

    def test_kill_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Kill exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["kill", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_kill_already_terminal(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Kill exits with code 1 for already terminal agent."""
        result = cli_runner.invoke(
            main,
            ["kill", completed_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "already in terminal state" in result.output.lower()

        state_file = get_agent_dir(completed_agent.agent_id) / "state.json"
        state_data = json.loads(state_file.read_text())
        assert state_data["state"] == "completed"

    def test_kill_running_agent(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill transitions running agent to killed state."""
        # Mock os.kill to avoid actually sending signal
        with patch("anima.shell.agent_kill.os.kill") as mock_kill:
            result = cli_runner.invoke(
                main,
                ["kill", running_agent.agent_id],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            calls = mock_kill.call_args_list
            assert len(calls) >= 2
            assert calls[0].args == (12345, signal.SIGTERM)
            assert any(call.args == (12345, signal.SIGKILL) for call in calls)

        # Verify state transition
        updated = read_state(running_agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.killed

    def test_kill_parent_cascades_to_children(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Killing parent transitions parent and children to killed."""
        parent_job = JobSpec(persona_ref="parent@test", instance_id="inst_parent")
        child1_job = JobSpec(persona_ref="child1@test", instance_id="inst_child1")
        child2_job = JobSpec(persona_ref="child2@test", instance_id="inst_child2")

        parent_result = create_agent_state("inst_parent", "parent", parent_job)
        child1_result = create_agent_state("inst_child1", "child1", child1_job)
        child2_result = create_agent_state("inst_child2", "child2", child2_job)

        assert isinstance(parent_result, Success)
        assert isinstance(child1_result, Success)
        assert isinstance(child2_result, Success)

        parent_state = AgentStateData(
            **{
                **parent_result.unwrap().model_dump(),
                "state": AgentState.running,
                "pid": 1111,
                "children": ["inst_child1", "inst_child2"],
            }
        )
        child1_state = AgentStateData(
            **{
                **child1_result.unwrap().model_dump(),
                "state": AgentState.running,
                "pid": 2222,
                "parent_id": "inst_parent",
            }
        )
        child2_state = AgentStateData(
            **{
                **child2_result.unwrap().model_dump(),
                "state": AgentState.running,
                "pid": 3333,
                "parent_id": "inst_parent",
            }
        )

        write_parent = write_state("inst_parent", parent_state, "start", {})
        write_child1 = write_state("inst_child1", child1_state, "start", {})
        write_child2 = write_state("inst_child2", child2_state, "start", {})
        assert isinstance(write_parent, Success)
        assert isinstance(write_child1, Success)
        assert isinstance(write_child2, Success)

        killed_pids: set[int] = set()

        def _fake_kill(pid: int, sig: int) -> None:
            if sig == signal.SIGKILL:
                killed_pids.add(pid)
                return
            if sig == 0 and pid in killed_pids:
                raise ProcessLookupError()

        with (
            patch("anima.shell.agent_kill.os.kill", side_effect=_fake_kill),
            patch("anima.shell.agent_kill.time.sleep", return_value=None),
        ):
            result = cli_runner.invoke(
                main,
                ["kill", "inst_parent"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0

        parent_after = read_state("inst_parent")
        child1_after = read_state("inst_child1")
        child2_after = read_state("inst_child2")
        assert isinstance(parent_after, Success)
        assert isinstance(child1_after, Success)
        assert isinstance(child2_after, Success)
        assert parent_after.unwrap().state == AgentState.killed
        assert child1_after.unwrap().state == AgentState.killed
        assert child2_after.unwrap().state == AgentState.killed

    def test_kill_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill outputs JSON when --json flag is set."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", running_agent.agent_id, "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            output = json.loads(result.output.strip())
            assert output["state"] == "killed"

    def test_kill_logs_state_transition(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill appends kill event to log."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", running_agent.agent_id],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0

        # Check log entry
        from anima.shell.state import read_log

        log_result = read_log(running_agent.agent_id)
        assert isinstance(log_result, Success)
        logs = log_result.unwrap()
        kill_entries = [e for e in logs if e.get("event") == "kill"]
        assert len(kill_entries) >= 1

    def test_kill_all_requires_confirmation(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill --all requires confirmation prompt (or --yes)."""
        with patch("anima.shell.agent_kill.os.kill"):
            # Without --yes, should require confirmation
            # In JSON mode, this should return an error requiring --yes
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 1
            output = json.loads(result.output.strip())
            assert output["error"]["code"] == "CONFIRMATION_REQUIRED"

    def test_kill_all_with_yes(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill --all --yes kills all killable agents."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--yes"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            assert "Killed 1 agent" in result.output

        # Verify state transition
        updated = read_state(running_agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.killed

    def test_kill_all_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Kill --all --yes --json outputs JSON result."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--yes", "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            output = json.loads(result.output.strip())
            assert output["killed"] == 1
            assert len(output["agents"]) == 1
            assert output["agents"][0]["agent_id"] == running_agent.agent_id

    def test_kill_all_state_filter(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        running_agent: AgentStateData,
        suspended_agent: AgentStateData,
    ) -> None:
        """Kill --all --state only kills agents in specified state."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--yes", "--state", "running"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            # Only running agent should be killed
            assert "Killed 1 agent" in result.output

        # Verify running agent was killed
        updated_running = read_state(running_agent.agent_id)
        assert isinstance(updated_running, Success)
        assert updated_running.unwrap().state == AgentState.killed

        # Verify suspended agent was NOT killed
        updated_suspended = read_state(suspended_agent.agent_id)
        assert isinstance(updated_suspended, Success)
        assert updated_suspended.unwrap().state == AgentState.suspended

    def test_kill_all_multiple_states(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        running_agent: AgentStateData,
        suspended_agent: AgentStateData,
        completed_agent: AgentStateData,
    ) -> None:
        """Kill --all only affects killable states, not terminal states."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--yes"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            # Should kill running and suspended (2 agents), not completed
            assert "Killed 2 agent" in result.output

    def test_kill_all_no_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Kill --all reports when no killable agents found."""
        # Only completed agent exists (not killable)
        result = cli_runner.invoke(
            main,
            ["kill", "--all", "--yes"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert (
            "No killable agents found" in result.output
            or "No agents found in states" in result.output
        )

    def test_kill_all_awaiting_approval(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Kill --all can kill agents in awaiting_approval state."""
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["kill", "--all", "--yes", "--state", "awaiting_approval"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            assert "Killed 1 agent" in result.output

        # Verify state transition
        updated = read_state(awaiting_approval_agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.killed

    def test_kill_requires_agent_id_without_all(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Kill without --all requires agent_id argument."""
        result = cli_runner.invoke(
            main,
            ["kill"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "agent_id is required" in result.output.lower()


# =============================================================================
# Approve Command Tests
# =============================================================================


class TestCliApprove:
    """Tests for 'anima approve' command."""

    def test_approve_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Approve exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["approve", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_approve_wrong_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Approve exits with code 1 when agent not in awaiting_approval."""
        result = cli_runner.invoke(
            main,
            ["approve", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "not awaiting approval" in result.output.lower()

    def test_approve_success(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Approve transitions awaiting_approval to running."""
        result = cli_runner.invoke(
            main,
            ["approve", awaiting_approval_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "running" in result.output.lower()

        # Verify state transition
        updated = read_state(awaiting_approval_agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.running

    def test_approve_with_note(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Approve stores note parameter in approval response channel."""
        result = cli_runner.invoke(
            main,
            ["approve", awaiting_approval_agent.agent_id, "--note", "LGTM"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "LGTM" in result.output

        response_result = read_approval_response(awaiting_approval_agent.agent_id)
        assert isinstance(response_result, Success)
        assert response_result.unwrap() == {"approved": True, "note": "LGTM"}

    def test_approve_json_output(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Approve outputs JSON when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["approve", awaiting_approval_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["state"] == "running"
        assert output["approved"] is True


# =============================================================================
# Reject Command Tests
# =============================================================================


class TestCliReject:
    """Tests for 'anima reject' command."""

    def test_reject_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Reject exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["reject", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_reject_wrong_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Reject exits with code 1 when agent not in awaiting_approval."""
        result = cli_runner.invoke(
            main,
            ["reject", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "not awaiting approval" in result.output.lower()

    def test_reject_success(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Reject transitions awaiting_approval to failed."""
        result = cli_runner.invoke(
            main,
            ["reject", awaiting_approval_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "failed" in result.output.lower()

        # Verify state transition
        updated = read_state(awaiting_approval_agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.failed

    def test_reject_with_reason(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Reject stores reason parameter."""
        result = cli_runner.invoke(
            main,
            ["reject", awaiting_approval_agent.agent_id, "--reason", "Not approved"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "Not approved" in result.output

    def test_reject_json_output(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Reject outputs JSON when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["reject", awaiting_approval_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["state"] == "failed"
        assert output["rejected"] is True


# =============================================================================
# Resume Command Tests
# =============================================================================


class TestCliResume:
    """Tests for 'anima resume' command."""

    def test_resume_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Resume exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["resume", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_resume_wrong_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Resume exits with code 1 when agent not in correct state."""
        result = cli_runner.invoke(
            main,
            ["resume", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1

    def test_resume_awaiting_approval(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Resume transitions awaiting_approval to running.

        Note: CLI currently allows resume from awaiting_approval (bug:
        lifecycle only allows from suspended, but CLI checks awaiting_approval).
        This test documents the current CLI behavior.
        """
        result = cli_runner.invoke(
            main,
            ["resume", awaiting_approval_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # The CLI checks for awaiting_approval, then calls next_state which
        # may fail because transition from awaiting_approval + resume is not valid
        # Expected: exits with error code due to invalid transition
        assert result.exit_code == 1

    def test_resume_suspended_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Resume transitions suspended to running."""
        # Create a suspended agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_suspended")
        result = create_agent_state("inst_suspended", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to suspended
        now = datetime.now(timezone.utc).isoformat()
        suspended_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.suspended,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, suspended_state, "suspend", {})

        # Resume should work for suspended state
        result = cli_runner.invoke(
            main,
            ["resume", agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Resume should succeed for suspended state
        assert result.exit_code == 0

    def test_resume_json_output(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        suspended_agent: AgentStateData,
    ) -> None:
        """Resume outputs JSON when --json flag is set for suspended state."""
        result = cli_runner.invoke(
            main,
            ["resume", suspended_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Resume should succeed for suspended state
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["state"] == "running"


# =============================================================================
# Monitor Command Tests
# =============================================================================


class TestCliMonitor:
    """Tests for 'anima monitor' command."""

    def test_monitor_shows_empty_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Monitor shows message when no agents."""
        # Monitor runs in a loop, so we can only test the initial output
        # Use a short timeout or just check it starts
        # For simplicity, test that it handles the empty state
        cli_runner.invoke(
            main,
            ["monitor", "--refresh", "0.1"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            timeout=2,  # Short timeout
        )
        # Monitor runs until interrupted, so exit code may be non-zero
        # Just check it started without crashing
        # The key is no exception was raised

    def test_monitor_shows_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Monitor displays running agents."""
        # Similar to above, just verify it doesn't crash with agents present
        cli_runner.invoke(
            main,
            ["monitor", "--refresh", "0.1"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
            timeout=2,
        )


# =============================================================================
# Serve Command Tests
# =============================================================================


class TestCliServe:
    """Tests for 'anima serve' command."""

    def test_serve_help_shows_options(self, cli_runner: CliRunner) -> None:
        """Serve shows port and host options in help."""
        result = cli_runner.invoke(main, ["serve", "--help"])
        assert result.exit_code == 0
        assert "--port" in result.output
        assert "--host" in result.output

    def test_serve_default_options(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Serve has correct default options."""
        result = cli_runner.invoke(main, ["serve", "--help"])
        assert result.exit_code == 0
        # Check defaults are shown in help
        assert "8420" in result.output or "port" in result.output.lower()
        assert "127.0.0.1" in result.output or "host" in result.output.lower()


# =============================================================================
# Talk Command Tests
# =============================================================================


class TestCliTalk:
    """Tests for 'anima talk' command."""

    def test_talk_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Talk exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["talk", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND

    def test_talk_terminal_state(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Talk exits with code 1 for terminal state."""
        result = cli_runner.invoke(
            main,
            ["talk", completed_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "terminal" in result.output.lower()

    def test_talk_pending_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Talk exits with code 1 for pending state (not interactive)."""
        result = cli_runner.invoke(
            main,
            ["talk", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "not interactive" in result.output.lower()

    def test_talk_missing_socket(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Talk exits with code 1 when socket is missing (internal/connection error)."""
        result = cli_runner.invoke(
            main,
            ["talk", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Should fail because socket doesn't exist - exit 1 for error
        assert result.exit_code == 1
        output = result.output.lower()
        assert "socket" in output or "terminated" in output or "stale state" in output

    def test_talk_json_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Talk outputs JSON error when agent not found."""
        result = cli_runner.invoke(
            main,
            ["talk", "nonexistent_agent", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_FOUND"


# =============================================================================
# Logs Command Tests
# =============================================================================


class TestCliLogs:
    """Tests for 'anima logs' command."""

    def test_logs_help(self, cli_runner: CliRunner) -> None:
        """Logs command shows help with options."""
        result = cli_runner.invoke(main, ["logs", "--help"])
        assert result.exit_code == 0
        assert "event log" in result.output.lower()
        assert "--tail" in result.output
        assert "--json" in result.output
        assert "--follow" in result.output
        assert "--follow TEXT" not in result.output

    def test_logs_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Logs exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["logs", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        assert "not found" in result.output.lower()

    def test_logs_follow_flag_no_argument(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Logs accepts --follow as a boolean flag without an argument."""
        result = cli_runner.invoke(
            main,
            ["logs", "nonexistent_agent", "--follow"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        assert "--follow is not yet implemented" not in result.output

    def test_logs_agent_not_found_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Logs outputs JSON error when agent not found."""
        result = cli_runner.invoke(
            main,
            ["logs", "nonexistent_agent", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_FOUND"
        assert output["error"]["numeric_code"] == 300

    def test_logs_empty_logs(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs shows message when no entries exist."""
        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "No log entries" in result.output

    def test_logs_with_entries(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs shows entries for agent with log history."""
        # Add some log entries
        append_log(created_agent.agent_id, "created", {"source": "test"})
        append_log(created_agent.agent_id, "start", {"pid": 12345})
        append_log(
            created_agent.agent_id,
            "state_transition",
            {"from": "pending", "to": "running"},
        )

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Check that event names appear in output
        assert "created" in result.output.lower()
        assert "start" in result.output.lower()

    def test_logs_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs outputs JSONL when --json flag is set."""
        # Add log entry
        append_log(created_agent.agent_id, "test_event", {"key": "value"})

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

        # Parse JSONL output
        lines = result.output.strip().split("\n")
        entries = [json.loads(line) for line in lines if line.strip()]

        # Should have at least one entry
        assert len(entries) >= 1

        # Check structure
        for entry in entries:
            assert "ts" in entry
            assert "event" in entry

    def test_logs_tail(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs --tail limits number of entries."""
        # Add multiple log entries
        for i in range(5):
            append_log(created_agent.agent_id, f"event_{i}", {"index": i})

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id, "--json", "--tail", "2"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

        # Parse output
        lines = result.output.strip().split("\n")
        entries = [json.loads(line) for line in lines if line.strip()]

        # Should only get last 2 entries
        assert len(entries) == 2
        assert entries[0]["event"] == "event_3"
        assert entries[1]["event"] == "event_4"

    def test_logs_prefix_match(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs finds agent by ID prefix."""
        append_log(created_agent.agent_id, "test_event", {})

        prefix = created_agent.agent_id[:8]
        result = cli_runner.invoke(
            main,
            ["logs", prefix],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "test_event" in result.output.lower()

    def test_logs_responses_filter(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs --responses filters to show only response events."""
        # Add mixed log entries
        append_log(created_agent.agent_id, "created", {"source": "test"})
        append_log(
            created_agent.agent_id,
            "response",
            {"response_text": "Hello, world!", "turn_number": 1},
        )
        append_log(created_agent.agent_id, "start", {"pid": 12345})
        append_log(
            created_agent.agent_id,
            "response",
            {"response_text": "How can I help?", "turn_number": 2},
        )

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id, "--responses"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Should show conversation history with turn numbers
        assert "Turn 1" in result.output
        assert "Hello, world!" in result.output
        assert "Turn 2" in result.output
        assert "How can I help?" in result.output
        # Should NOT show other events
        assert (
            "created" not in result.output.lower()
            or "Conversation History" in result.output
        )
        assert "start" not in result.output.lower() or "Turn" in result.output

    def test_logs_responses_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs --responses --json outputs filtered response events as JSONL."""
        # Add response events
        append_log(
            created_agent.agent_id,
            "response",
            {"response_text": "First response", "turn_number": 1},
        )
        append_log(
            created_agent.agent_id,
            "response",
            {"response_text": "Second response", "turn_number": 2},
        )
        append_log(created_agent.agent_id, "created", {"source": "test"})

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id, "--responses", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

        # Parse JSONL output
        lines = result.output.strip().split("\n")
        entries = [json.loads(line) for line in lines if line.strip()]

        # Should only have response events
        assert len(entries) == 2
        for entry in entries:
            assert entry["event"] == "response"
            assert "turn_number" in entry["details"]
            assert "response_text" in entry["details"]

    def test_logs_responses_empty(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Logs --responses shows message when no response events."""
        # Add non-response events only
        append_log(created_agent.agent_id, "created", {"source": "test"})
        append_log(created_agent.agent_id, "start", {"pid": 12345})

        result = cli_runner.invoke(
            main,
            ["logs", created_agent.agent_id, "--responses"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "No response events" in result.output


# =============================================================================
# Integration Tests
# =============================================================================


class TestCliIntegration:
    """Integration tests covering multiple command interactions."""

    def test_lifecycle_create_status_collect(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Test creating agent, checking status, and collecting result."""
        # Create agent state directly
        job = JobSpec(persona_ref="test@test", instance_id="inst_lifecycle")
        create_result = create_agent_state("inst_lifecycle", "test-persona", job)
        assert isinstance(create_result, Success)
        agent = create_result.unwrap()

        # Check status
        status_result = cli_runner.invoke(
            main,
            ["status", agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert status_result.exit_code == 0
        status_output = json.loads(status_result.output.strip())
        assert status_output["state"] == "pending"

        # Transition to completed
        now = datetime.now(timezone.utc).isoformat()
        completed_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.completed,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, completed_state, "finish", {})

        # Write result
        agent_result = AgentResult(
            final_state=AgentState.completed,
            token_usage=TokenUsage(prompt_tokens=200, completion_tokens=100),
        )
        write_result(agent.agent_id, agent_result)

        # Collect result
        collect_result = cli_runner.invoke(
            main,
            ["collect", agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert collect_result.exit_code == 0
        collect_output = json.loads(collect_result.output.strip())
        assert collect_output["final_state"] == "completed"
        assert collect_output["token_usage"]["total_tokens"] == 300

    def test_ambiguous_prefix(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """Status handles prefix matching correctly."""
        # Create two agents
        job1 = JobSpec(persona_ref="test@test", instance_id="inst_1")
        job2 = JobSpec(persona_ref="test@test", instance_id="inst_2")

        result1 = create_agent_state("inst_1", "persona-1", job1)
        result2 = create_agent_state("inst_2", "persona-2", job2)

        if isinstance(result1, Success) and isinstance(result2, Success):
            agent1 = result1.unwrap()
            agent2 = result2.unwrap()

            # Both should start with "inst_"
            assert agent1.agent_id.startswith("inst_")
            assert agent2.agent_id.startswith("inst_")

            # Query with prefix "inst_" - may match one or be ambiguous
            result = cli_runner.invoke(
                main,
                ["status", "inst_"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            # Either returns first match or fails with ambiguous
            # The behavior is acceptable either way
            assert result.exit_code in (0, EXIT_AGENT_NOT_FOUND, 1)

            # Query with unique prefix should work
            unique_prefix = agent1.agent_id[:12]  # Longer prefix for uniqueness
            result_unique = cli_runner.invoke(
                main,
                ["status", unique_prefix],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            # Should find the agent
            assert result_unique.exit_code == 0

    def test_state_transitions_via_cli(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Test state transitions through CLI commands."""
        # Create agent in awaiting_approval state
        job = JobSpec(persona_ref="test@test", instance_id="inst_transition")
        create_result = create_agent_state("inst_transition", "test-persona", job)
        agent = create_result.unwrap()

        # Transition to awaiting_approval
        now = datetime.now(timezone.utc).isoformat()
        approval_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.awaiting_approval,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, approval_state, "request_approval", {})

        # Reject via CLI
        reject_result = cli_runner.invoke(
            main,
            ["reject", agent.agent_id, "--reason", "Test rejection", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert reject_result.exit_code == 0

        # Verify state
        updated = read_state(agent.agent_id)
        assert isinstance(updated, Success)
        assert updated.unwrap().state == AgentState.failed

    def test_json_output_consistency(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Test JSON output structure is consistent across commands."""
        # Status
        status_result = cli_runner.invoke(
            main,
            ["status", running_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert status_result.exit_code == 0
        status_output = json.loads(status_result.output.strip())
        assert "agent_id" in status_output
        assert "state" in status_output

        # List
        list_result = cli_runner.invoke(
            main,
            ["list", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert list_result.exit_code == 0
        list_output = json.loads(list_result.output.strip())
        assert isinstance(list_output, list)
        assert len(list_output) >= 1
        assert "agent_id" in list_output[0]
        assert "state" in list_output[0]


# =============================================================================
# Rm Command Tests
# =============================================================================


class TestCliRm:
    """Tests for 'anima rm' command."""

    def test_rm_help(self, cli_runner: CliRunner) -> None:
        """Rm command shows help with options."""
        result = cli_runner.invoke(main, ["rm", "--help"])
        assert result.exit_code == 0
        assert "Delete" in result.output or "delete" in result.output.lower()
        assert "--all" in result.output
        assert "--yes" in result.output
        assert "--force" in result.output
        assert "--json" in result.output

    def test_rm_requires_agent_id_or_all(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm requires either AGENT_ID or --all."""
        result = cli_runner.invoke(
            main, ["rm"], env={"ANIMA_STATE": str(tmp_state_dir)}
        )
        assert result.exit_code == 1
        assert "Either AGENT_ID or --all is required" in result.output

    def test_rm_rejects_all_with_agent_id(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Rm rejects combining AGENT_ID with --all."""
        result = cli_runner.invoke(
            main,
            ["rm", completed_agent.agent_id, "--all"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "Cannot use AGENT_ID with --all" in result.output

    def test_rm_all_json_empty(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm --all --json returns empty summary when no agents exist."""
        result = cli_runner.invoke(
            main,
            ["rm", "--all", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output == {"deleted": 0, "killed_first": 0, "agents": []}

    def test_rm_all_force_yes_deletes_all(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
        created_agent: AgentStateData,
        running_agent: AgentStateData,
    ) -> None:
        """Rm --all --force --yes deletes terminal and non-terminal agents."""
        with patch("anima.shell.agent_kill.os.kill") as mock_kill:
            result = cli_runner.invoke(
                main,
                ["rm", "--all", "--force", "--yes", "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            output = json.loads(result.output.strip())
            assert output["deleted"] == 3
            assert output["killed_first"] == 1
            assert len(output["agents"]) == 3
            mock_kill.assert_called_once()

        from anima.shell.state import read_state

        assert isinstance(read_state(completed_agent.agent_id), Failure)
        assert isinstance(read_state(created_agent.agent_id), Failure)
        assert isinstance(read_state(running_agent.agent_id), Failure)

    def test_rm_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["rm", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        assert "not found" in result.output.lower()

    def test_rm_agent_not_found_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm outputs JSON error when agent not found."""
        result = cli_runner.invoke(
            main,
            ["rm", "nonexistent_agent", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_FOUND"
        assert output["error"]["numeric_code"] == 300

    def test_rm_terminal_agent(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm deletes terminal-state agent without --force."""
        # Create a completed agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_completed")
        result = create_agent_state("inst_completed", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to completed
        now = datetime.now(timezone.utc).isoformat()
        completed_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.completed,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, completed_state, "finish", {})

        # Delete without --force should succeed
        result = cli_runner.invoke(
            main,
            ["rm", agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "Deleted" in result.output or "deleted" in result.output.lower()

        # Agent should be gone
        from anima.shell.state import read_state

        check = read_state(agent.agent_id)
        assert isinstance(check, Failure)

    def test_rm_terminal_agent_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm outputs JSON on successful delete."""
        # Create a completed agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_completed_json")
        result = create_agent_state("inst_completed_json", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to completed
        now = datetime.now(timezone.utc).isoformat()
        completed_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.completed,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, completed_state, "finish", {})

        # Delete with --json
        result = cli_runner.invoke(
            main,
            ["rm", agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["agent_id"] == agent.agent_id
        assert output["deleted"] is True

    def test_rm_non_terminal_without_force(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Rm refuses to delete non-terminal agent without --force."""
        result = cli_runner.invoke(
            main,
            ["rm", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "non-terminal" in result.output.lower()
        assert "--force" in result.output.lower()

    def test_rm_non_terminal_json_without_force(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Rm outputs JSON error for non-terminal agent without --force."""
        result = cli_runner.invoke(
            main,
            ["rm", running_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_TERMINAL"
        assert output["error"]["numeric_code"] == 315

    def test_rm_pending_without_force(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Rm refuses to delete pending agent without --force."""
        result = cli_runner.invoke(
            main,
            ["rm", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "non-terminal" in result.output.lower()

    def test_rm_running_with_force(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Rm --force kills running agent then deletes."""
        with patch("anima.shell.agent_kill.os.kill") as mock_kill:
            result = cli_runner.invoke(
                main,
                ["rm", running_agent.agent_id, "--force"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            # Should have sent SIGTERM
            mock_kill.assert_called_once()
            # Agent should be deleted
            from anima.shell.state import read_state

            check = read_state(running_agent.agent_id)
            assert isinstance(check, Failure)

    def test_rm_running_with_force_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Rm --force --json shows killed_first flag."""
        # Create a running agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_running_rm_json")
        result = create_agent_state("inst_running_rm_json", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to running
        now = datetime.now(timezone.utc).isoformat()
        running_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.running,
                "pid": 12345,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, running_state, "start", {})

        # Delete with --force --json
        with patch("anima.shell.agent_kill.os.kill"):
            result = cli_runner.invoke(
                main,
                ["rm", agent.agent_id, "--force", "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            output = json.loads(result.output.strip())
            assert output["agent_id"] == agent.agent_id
            assert output["deleted"] is True
            assert output["killed_first"] is True

    def test_rm_suspended_without_force(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        suspended_agent: AgentStateData,
    ) -> None:
        """Rm refuses to delete suspended agent without --force."""
        result = cli_runner.invoke(
            main,
            ["rm", suspended_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "non-terminal" in result.output.lower()

    def test_rm_suspended_with_force(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        suspended_agent: AgentStateData,
    ) -> None:
        """Rm --force kills suspended agent then deletes."""
        # Note: suspended_agent fixture doesn't have PID set, so os.kill won't be called
        # But state transition to killed should still happen
        result = cli_runner.invoke(
            main,
            ["rm", suspended_agent.agent_id, "--force"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Agent should be deleted
        from anima.shell.state import read_state

        check = read_state(suspended_agent.agent_id)
        assert isinstance(check, Failure)

    def test_rm_awaiting_approval_without_force(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Rm refuses to delete awaiting_approval agent without --force."""
        result = cli_runner.invoke(
            main,
            ["rm", awaiting_approval_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "non-terminal" in result.output.lower()

    def test_rm_awaiting_approval_with_force(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        awaiting_approval_agent: AgentStateData,
    ) -> None:
        """Rm --force kills awaiting_approval agent then deletes."""
        # Note: awaiting_approval_agent fixture doesn't have PID set, so os.kill won't be called
        # But state transition to killed should still happen
        result = cli_runner.invoke(
            main,
            ["rm", awaiting_approval_agent.agent_id, "--force"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Agent should be deleted
        from anima.shell.state import read_state

        check = read_state(awaiting_approval_agent.agent_id)
        assert isinstance(check, Failure)

    def test_rm_prefix_match(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Rm finds agent by ID prefix."""
        prefix = completed_agent.agent_id[:8]
        result = cli_runner.invoke(
            main,
            ["rm", prefix],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

    def test_rm_killed_agent(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """Rm deletes killed agent (terminal state)."""
        # Create a killed agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_killed")
        result = create_agent_state("inst_killed", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to killed
        now = datetime.now(timezone.utc).isoformat()
        killed_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.killed,
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, killed_state, "kill", {})

        # Delete without --force should succeed (killed is terminal)
        result = cli_runner.invoke(
            main,
            ["rm", agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

    def test_rm_failed_agent(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """Rm deletes failed agent (terminal state)."""
        # Create a failed agent
        job = JobSpec(persona_ref="test@test", instance_id="inst_failed")
        result = create_agent_state("inst_failed", "test-persona", job)
        assert isinstance(result, Success)
        agent = result.unwrap()

        # Transition to failed
        now = datetime.now(timezone.utc).isoformat()
        failed_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.failed,
                "error": "Test failure",
                "updated_at": now,
            }
        )
        write_state(agent.agent_id, failed_state, "fail", {})

        # Delete without --force should succeed (failed is terminal)
        result = cli_runner.invoke(
            main,
            ["rm", agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0


# =============================================================================
# Inspect Command Tests
# =============================================================================


class TestCliInspect:
    """Tests for 'anima inspect' command."""

    def test_inspect_help(self, cli_runner: CliRunner) -> None:
        """Inspect command shows help with options."""
        result = cli_runner.invoke(main, ["inspect", "--help"])
        assert result.exit_code == 0
        assert "spec" in result.output.lower() or "config" in result.output.lower()
        assert "--json" in result.output

    def test_inspect_agent_not_found(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Inspect exits with code 2 when agent not found."""
        result = cli_runner.invoke(
            main,
            ["inspect", "nonexistent_agent"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        assert "not found" in result.output.lower()

    def test_inspect_agent_not_found_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Inspect outputs JSON error when agent not found."""
        result = cli_runner.invoke(
            main,
            ["inspect", "nonexistent_agent", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == EXIT_AGENT_NOT_FOUND
        output = json.loads(result.output.strip())
        assert "error" in output
        assert output["error"]["code"] == "AGENT_NOT_FOUND"

    def test_inspect_success(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Inspect shows agent details in human-readable format."""
        result = cli_runner.invoke(
            main,
            ["inspect", running_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert running_agent.agent_id in result.output
        assert "running" in result.output.lower()

    def test_inspect_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Inspect outputs JSON when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["inspect", created_agent.agent_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert output["agent_id"] == created_agent.agent_id
        assert output["state"] == "pending"

    def test_inspect_prefix_match(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Inspect finds agent by ID prefix."""
        prefix = created_agent.agent_id[:8]
        result = cli_runner.invoke(
            main,
            ["inspect", prefix],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert created_agent.agent_id in result.output

    def test_inspect_shows_hierarchy(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Inspect shows hierarchy information (parent_id, children)."""
        result = cli_runner.invoke(
            main,
            ["inspect", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Should show hierarchy section
        assert "Hierarchy" in result.output or "Parent" in result.output
        assert "Children" in result.output

    def test_inspect_shows_budget(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Inspect shows budget information."""
        result = cli_runner.invoke(
            main,
            ["inspect", created_agent.agent_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Should show budget section
        assert "Budget" in result.output or "Max Turns" in result.output


# =============================================================================
# Tree Command Tests
# =============================================================================


class TestCliTree:
    """Tests for 'anima tree' command."""

    def test_tree_help(self, cli_runner: CliRunner) -> None:
        """Tree command shows help with options."""
        result = cli_runner.invoke(main, ["tree", "--help"])
        assert result.exit_code == 0
        assert (
            "hierarchical" in result.output.lower() or "agent" in result.output.lower()
        )
        assert "--state" in result.output
        assert "--json" in result.output

    def test_tree_empty(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """Tree shows no agents message when empty."""
        result = cli_runner.invoke(
            main,
            ["tree"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "No agents found" in result.output

    def test_tree_empty_json(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        """Tree --json outputs empty array when no agents."""
        result = cli_runner.invoke(
            main,
            ["tree", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        # Empty state should return empty array
        assert output == []

    def test_tree_shows_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Tree shows agents in tree format."""
        result = cli_runner.invoke(
            main,
            ["tree"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Agent ID (truncated to 8 chars) should be in output
        assert created_agent.agent_id[:8] in result.output

    def test_tree_json_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, created_agent: AgentStateData
    ) -> None:
        """Tree outputs JSON array when --json flag is set."""
        result = cli_runner.invoke(
            main,
            ["tree", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert isinstance(output, list)
        assert len(output) == 1
        assert output[0]["agent_id"] == created_agent.agent_id

    def test_tree_filter_by_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Tree filters by state when --state flag is used."""
        result = cli_runner.invoke(
            main,
            ["tree", "--state", "running"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "running" in result.output.lower()

    def test_tree_invalid_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Tree fails with error for invalid state filter."""
        result = cli_runner.invoke(
            main,
            ["tree", "--state", "invalid_state"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 1
        assert "Invalid state" in result.output

    def test_tree_multiple_agents(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Tree shows multiple agents."""
        job1 = JobSpec(persona_ref="test@test", instance_id="inst_tree_1")
        job2 = JobSpec(persona_ref="test@test", instance_id="inst_tree_2")
        create_agent_state("inst_tree_1", "persona-1", job1)
        create_agent_state("inst_tree_2", "persona-2", job2)

        result = cli_runner.invoke(
            main,
            ["tree", "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert len(output) == 2


# =============================================================================
# Prune Command Tests
# =============================================================================


class TestCliPrune:
    """Tests for 'anima prune' command."""

    def test_prune_help(self, cli_runner: CliRunner) -> None:
        """Prune command shows help with options."""
        result = cli_runner.invoke(main, ["prune", "--help"])
        assert result.exit_code == 0
        assert "terminal" in result.output.lower() or "prune" in result.output.lower()
        assert "--state" in result.output
        assert "--older-than" in result.output
        assert "--yes" in result.output

    def test_prune_dry_run_empty(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Prune shows no agents when empty."""
        result = cli_runner.invoke(
            main,
            ["prune"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert "No agents" in result.output or "Found 0" in result.output

    def test_prune_dry_run_shows_terminal(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Prune (dry-run) lists terminal agents without deleting."""
        result = cli_runner.invoke(
            main,
            ["prune"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        # Should show the agent but not delete
        assert completed_agent.agent_id[:8] in result.output

        # Agent should still exist
        from anima.shell.state import read_state

        check = read_state(completed_agent.agent_id)
        assert isinstance(check, Success)

    def test_prune_yes_deletes_terminal(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Prune --yes deletes terminal agents."""
        result = cli_runner.invoke(
            main,
            ["prune", "--yes"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        assert (
            "Deleted" in result.output
            or "deleted" in result.output.lower()
            or "Pruned" in result.output
        )

        # Agent should be deleted
        from anima.shell.state import read_state

        check = read_state(completed_agent.agent_id)
        assert isinstance(check, Failure)

    def test_prune_state_filter(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Prune --state only prunes specified states."""
        # Create a completed agent
        job1 = JobSpec(persona_ref="test@test", instance_id="inst_prune_completed")
        result1 = create_agent_state("inst_prune_completed", "persona-1", job1)
        assert isinstance(result1, Success)
        agent1 = result1.unwrap()
        now = datetime.now(timezone.utc).isoformat()
        write_state(
            agent1.agent_id,
            AgentStateData(
                **{
                    **agent1.model_dump(),
                    "state": AgentState.completed,
                    "updated_at": now,
                }
            ),
            "finish",
            {},
        )

        # Create a failed agent
        job2 = JobSpec(persona_ref="test@test", instance_id="inst_prune_failed")
        result2 = create_agent_state("inst_prune_failed", "persona-2", job2)
        assert isinstance(result2, Success)
        agent2 = result2.unwrap()
        write_state(
            agent2.agent_id,
            AgentStateData(
                **{
                    **agent2.model_dump(),
                    "state": AgentState.failed,
                    "error": "test",
                    "updated_at": now,
                }
            ),
            "fail",
            {},
        )

        # Create a killed agent
        job3 = JobSpec(persona_ref="test@test", instance_id="inst_prune_killed")
        result3 = create_agent_state("inst_prune_killed", "persona-3", job3)
        assert isinstance(result3, Success)
        agent3 = result3.unwrap()
        write_state(
            agent3.agent_id,
            AgentStateData(
                **{**agent3.model_dump(), "state": AgentState.killed, "updated_at": now}
            ),
            "kill",
            {},
        )

        # Prune only failed agents
        result = cli_runner.invoke(
            main,
            ["prune", "--state", "failed", "--yes"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

        # Only failed agent should be deleted
        from anima.shell.state import read_state

        assert isinstance(
            read_state(agent1.agent_id), Success
        )  # completed still exists
        assert isinstance(read_state(agent2.agent_id), Failure)  # failed was deleted
        assert isinstance(read_state(agent3.agent_id), Success)  # killed still exists

    def test_prune_json_output(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        completed_agent: AgentStateData,
    ) -> None:
        """Prune --json outputs JSON format."""
        result = cli_runner.invoke(
            main,
            ["prune", "--json", "--yes"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert "deleted" in output or "count" in output or "agents" in output

    def test_prune_skips_non_terminal(
        self, cli_runner: CliRunner, tmp_state_dir: Path, running_agent: AgentStateData
    ) -> None:
        """Prune does not delete non-terminal agents."""
        result = cli_runner.invoke(
            main,
            ["prune", "--yes"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert result.exit_code == 0

        # Running agent should still exist
        from anima.shell.state import read_state

        check = read_state(running_agent.agent_id)
        assert isinstance(check, Success)

    def test_prune_invalid_state(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Prune fails with invalid state filter."""
        result = cli_runner.invoke(
            main,
            ["prune", "--state", "running"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Click validates choices before our code runs, exits with 2
        assert result.exit_code == 2
        # Click shows "Invalid value" for invalid choices
        assert "Invalid value" in result.output or "invalid" in result.output.lower()

    def test_prune_invalid_state_string(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Prune fails with unrecognized state string."""
        result = cli_runner.invoke(
            main,
            ["prune", "--state", "nonexistent_state"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        # Click validates choices and exits with 2
        assert result.exit_code == 2
        assert "Invalid" in result.output or "invalid" in result.output.lower()


# =============================================================================
# Inline Persona Tests (run/spawn)
# =============================================================================


class TestCliInlinePersona:
    """Tests for inline persona in run/spawn commands."""

    def test_run_inline_system_prompt(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run accepts --system-prompt instead of --persona-file."""
        from unittest.mock import MagicMock, AsyncMock, patch
        from returns.result import Success

        mock_runner_class = MagicMock()
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        with patch("anima.shell.agent.AgentRunner", mock_runner_class):
            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "test-model",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        # Check result - show output if failed
        if result.exit_code != 0:
            print(f"Exit code: {result.exit_code}")
            print(f"Output: {result.output}")
            if result.exception:
                print(f"Exception: {result.exception}")
        assert result.exit_code == 0

    def test_spawn_inline_system_prompt(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Spawn accepts --system-prompt instead of --persona-file."""
        from unittest.mock import MagicMock, patch

        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_proc = MagicMock()
            mock_proc.pid = 12345
            mock_popen.return_value = mock_proc

            result = cli_runner.invoke(
                main,
                [
                    "spawn",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "test-model",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0
        # Check that agent was created
        assert (
            "inst_" in result.output
            or "ag_" in result.output
            or "pending" in result.output.lower()
        )
        popen_cmd = mock_popen.call_args.args[0]
        assert "--persona-file" in popen_cmd
        assert "--persona-spec-id" not in popen_cmd

    def test_spawn_persona_spec_id_passes_internal_spec_flag(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Spawn forwards --persona spec IDs via internal --persona-spec-id flag."""
        from unittest.mock import MagicMock, patch

        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_proc = MagicMock()
            mock_proc.pid = 12345
            mock_popen.return_value = mock_proc

            result = cli_runner.invoke(
                main,
                [
                    "spawn",
                    "Test task",
                    "--persona",
                    "python-senior",
                    "--model",
                    "test-model",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0
        popen_cmd = mock_popen.call_args.args[0]
        assert "--persona-spec-id" in popen_cmd
        spec_id_index = popen_cmd.index("--persona-spec-id")
        assert popen_cmd[spec_id_index + 1] == "python-senior"
        assert "--persona-file" not in popen_cmd

    def test_run_inline_system_prompt_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run --system-prompt with --json outputs JSON."""
        from unittest.mock import MagicMock, AsyncMock, patch
        from returns.result import Success

        mock_runner_class = MagicMock()
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        with patch("anima.shell.agent.AgentRunner", mock_runner_class):
            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "test-model",
                    "--json",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert "agent_id" in output
        assert output["final_state"] == "completed"

    def test_spawn_inline_system_prompt_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Spawn --system-prompt with --json outputs JSON."""
        from unittest.mock import MagicMock, patch

        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_proc = MagicMock()
            mock_proc.pid = 12345
            mock_popen.return_value = mock_proc

            result = cli_runner.invoke(
                main,
                [
                    "spawn",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "test-model",
                    "--json",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert "instance_id" in output
        assert output["state"] == "pending"

    def test_run_inline_with_model_override(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run --system-prompt with --model override."""
        from unittest.mock import MagicMock, AsyncMock, patch
        from returns.result import Success

        mock_runner_class = MagicMock()
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                )
            )
        )
        mock_runner_class.return_value = mock_runner

        with patch("anima.shell.agent.AgentRunner", mock_runner_class):
            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "gpt-4",
                    "--json",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )

        assert result.exit_code == 0
        output = json.loads(result.output.strip())
        assert "agent_id" in output
        assert output["final_state"] == "completed"

    def test_run_inline_uses_model_from_config_toml(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run succeeds with --system-prompt when model comes from config.toml."""
        from unittest.mock import AsyncMock, MagicMock, patch

        from returns.result import Success

        config_path = tmp_state_dir / "config.toml"
        config_path.write_text('[defaults]\nmodel = "openai:gpt-4o-mini"\n')

        captured_kwargs: dict[str, object] = {}

        mock_runner_class = MagicMock()
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=10, completion_tokens=5),
                )
            )
        )

        def capture_init(*args: object, **kwargs: object) -> MagicMock:
            captured_kwargs.update(kwargs)
            return mock_runner

        mock_runner_class.side_effect = capture_init

        with patch("anima.shell.agent.AgentRunner", mock_runner_class):
            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--json",
                ],
                env={
                    "ANIMA_STATE": str(tmp_state_dir),
                    "ANIMA_CONFIG": str(config_path),
                },
            )

        assert result.exit_code == 0
        persona_arg = captured_kwargs.get("persona")
        assert isinstance(persona_arg, PersonaSpec)
        assert persona_arg.model == "openai:gpt-4o-mini"

    def test_run_inline_missing_model_shows_tutorial_error(
        self,
        cli_runner: CliRunner,
        tmp_state_dir: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Run without model configuration shows tutorial-style guidance."""
        # Ensure env vars are clean
        monkeypatch.delenv("ANIMA_DEFAULT_MODEL", raising=False)
        # DO NOT delete ANIMA_CONFIG here, so we use the isolated empty one from conftest.py

        result = cli_runner.invoke(
            main,
            [
                "run",
                "Test task",
                "--system-prompt",
                "You are a helpful assistant.",
            ],
            env={
                "ANIMA_STATE": str(tmp_state_dir),
            },
        )

        assert result.exit_code == 1
        assert "Error: No model configured for this run." in result.output
        assert "Quick fix" in result.output
        assert "--model openai:gpt-5.2" in result.output
        assert "Permanent fix" in result.output
        assert "~/.anima/config.toml" in result.output
        assert 'model = "openai:gpt-5.2"' in result.output
        assert "config.toml.example" in result.output
        assert "Resolution order" in result.output
        assert "ANIMA_DEFAULT_MODEL" in result.output
        assert "PersonaSpec.model" in result.output

    def test_run_inline_cli_model_overrides_config_model(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        """Run merges config with CLI flags: --model overrides config model."""
        from unittest.mock import AsyncMock, MagicMock, patch

        from returns.result import Success

        config_path = tmp_state_dir / "config.toml"
        config_path.write_text('[defaults]\nmodel = "openai:gpt-4o-mini"\n')

        captured_kwargs: dict[str, object] = {}

        mock_runner_class = MagicMock()
        mock_runner = MagicMock()
        mock_runner.run = AsyncMock(
            return_value=Success(
                AgentResult(
                    final_state=AgentState.completed,
                    token_usage=TokenUsage(prompt_tokens=10, completion_tokens=5),
                )
            )
        )

        def capture_init(*args: object, **kwargs: object) -> MagicMock:
            captured_kwargs.update(kwargs)
            return mock_runner

        mock_runner_class.side_effect = capture_init

        with patch("anima.shell.agent.AgentRunner", mock_runner_class):
            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "Test task",
                    "--system-prompt",
                    "You are a helpful assistant.",
                    "--model",
                    "openai:gpt-5",
                    "--json",
                ],
                env={
                    "ANIMA_STATE": str(tmp_state_dir),
                    "ANIMA_CONFIG": str(config_path),
                },
            )

        assert result.exit_code == 0
        persona_arg = captured_kwargs.get("persona")
        assert isinstance(persona_arg, PersonaSpec)
        assert persona_arg.model == "openai:gpt-5"
