521 lines
22 KiB
Python
521 lines
22 KiB
Python
"""Tests for AgentKit CLI"""
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class TestVersionCommand:
|
|
def test_version_outputs_version_string(self):
|
|
"""agentkit version outputs version number"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["version"])
|
|
assert result.exit_code == 0
|
|
assert "0.1.0" in result.stdout or "fischer-agentkit" in result.stdout
|
|
|
|
def test_version_help(self):
|
|
"""agentkit version --help works"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["version", "--help"])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestDoctorCommand:
|
|
def test_doctor_server_not_running(self):
|
|
"""agentkit doctor returns error when server not running"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["doctor"])
|
|
# Should show connection error or "not running"
|
|
assert result.exit_code != 0 or "not running" in result.stdout.lower() or "connection" in result.stdout.lower() or "error" in result.stdout.lower()
|
|
|
|
def test_doctor_with_custom_port(self):
|
|
"""agentkit doctor --port 9000 uses custom port"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client") as mock_client:
|
|
result = runner.invoke(app, ["doctor", "--port", "9000"])
|
|
# Should attempt to connect to port 9000
|
|
|
|
def test_doctor_server_running(self):
|
|
"""agentkit doctor returns ok when server is running"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"status": "ok"}
|
|
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, ["doctor"])
|
|
# Should show healthy status
|
|
|
|
|
|
class TestServeCommand:
|
|
def test_serve_help(self):
|
|
"""agentkit serve --help shows options"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["serve", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "--host" in result.stdout
|
|
assert "--port" in result.stdout
|
|
|
|
def test_serve_starts_uvicorn(self):
|
|
"""agentkit serve calls uvicorn.run with correct params"""
|
|
from agentkit.cli.main import app
|
|
with patch("uvicorn.run") as mock_run, \
|
|
patch("agentkit.server.config.find_config_path", return_value=None), \
|
|
patch("rich.prompt.Confirm.ask", return_value=False):
|
|
result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"])
|
|
mock_run.assert_called_once()
|
|
call_kwargs = mock_run.call_args
|
|
assert "0.0.0.0" in str(call_kwargs) or 8001 in str(call_kwargs)
|
|
|
|
|
|
class TestMainModule:
|
|
def test_help_shows_all_commands(self):
|
|
"""agentkit --help shows all subcommands"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "serve" in result.stdout
|
|
assert "version" in result.stdout
|
|
assert "doctor" in result.stdout
|
|
|
|
def test_main_module_entry(self):
|
|
"""python -m agentkit works"""
|
|
# Just verify the module can be imported
|
|
import agentkit.__main__
|
|
|
|
|
|
class TestTaskCommands:
|
|
def test_task_help(self):
|
|
"""agentkit task --help shows subcommands"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["task", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "submit" in result.stdout
|
|
assert "status" in result.stdout
|
|
assert "list" in result.stdout
|
|
assert "cancel" in result.stdout
|
|
|
|
def test_task_submit_remote_mode(self):
|
|
"""agentkit task submit --server-url calls API"""
|
|
from agentkit.cli.main import app
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {"result": "ok"}})
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "submit",
|
|
"--server-url", "http://localhost:8001",
|
|
"--skill", "content_generator",
|
|
"--input", '{"topic": "AI"}',
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
def test_task_submit_async_mode(self):
|
|
"""agentkit task submit --mode async returns task_id"""
|
|
from agentkit.cli.main import app
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.submit_task_async = AsyncMock(return_value={"task_id": "abc-123", "status": "pending"})
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "submit",
|
|
"--server-url", "http://localhost:8001",
|
|
"--skill", "content_generator",
|
|
"--mode", "async",
|
|
"--input", '{"topic": "AI"}',
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "abc-123" in result.stdout or "pending" in result.stdout
|
|
|
|
def test_task_status(self):
|
|
"""agentkit task status <id> shows status"""
|
|
from agentkit.cli.main import app
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.get_task_status = AsyncMock(return_value={
|
|
"task_id": "abc-123",
|
|
"status": "completed",
|
|
"output_data": {"result": "ok"},
|
|
})
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "status", "abc-123",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "completed" in result.stdout
|
|
|
|
def test_task_list(self):
|
|
"""agentkit task list shows tasks"""
|
|
from agentkit.cli.main import app
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.list_tasks = AsyncMock(return_value=[
|
|
{"task_id": "abc-123", "status": "completed", "agent_name": "test"},
|
|
])
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "list",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
def test_task_cancel(self):
|
|
"""agentkit task cancel <id> cancels task"""
|
|
from agentkit.cli.main import app
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.cancel_task = AsyncMock(return_value={"task_id": "abc-123", "status": "cancelled"})
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "cancel", "abc-123",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
def test_task_submit_input_file(self):
|
|
"""agentkit task submit --input-file reads from file"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump({"topic": "AI"}, f)
|
|
f.flush()
|
|
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
|
|
mock_client = MagicMock()
|
|
mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {}})
|
|
mock_client_cls.return_value = mock_client
|
|
result = runner.invoke(app, [
|
|
"task", "submit",
|
|
"--server-url", "http://localhost:8001",
|
|
"--skill", "content_generator",
|
|
"--input-file", f.name,
|
|
])
|
|
assert result.exit_code == 0
|
|
os.unlink(f.name)
|
|
|
|
def test_task_submit_no_server_url_shows_error(self):
|
|
"""agentkit task submit without --server-url shows error"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, [
|
|
"task", "submit",
|
|
"--skill", "content_generator",
|
|
"--input", '{"topic": "AI"}',
|
|
])
|
|
# Should show error about missing server URL or local mode not available
|
|
assert result.exit_code != 0 or "server" in result.stdout.lower() or "error" in result.stdout.lower()
|
|
|
|
|
|
class TestSkillCommands:
|
|
def test_skill_help(self):
|
|
"""agentkit skill --help shows subcommands"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["skill", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "list" in result.stdout
|
|
assert "load" in result.stdout
|
|
assert "info" in result.stdout
|
|
|
|
def test_skill_list_remote(self):
|
|
"""agentkit skill list --server-url calls API"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = [
|
|
{"name": "content_generator", "agent_type": "llm", "description": "Generate content"},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, [
|
|
"skill", "list",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "content_generator" in result.stdout
|
|
|
|
def test_skill_list_empty(self):
|
|
"""agentkit skill list with no skills shows empty message"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = []
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, [
|
|
"skill", "list",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "no skill" in result.stdout.lower() or "0" in result.stdout or "empty" in result.stdout.lower()
|
|
|
|
def test_skill_info_remote(self):
|
|
"""agentkit skill info <name> shows skill details"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"name": "content_generator",
|
|
"agent_type": "llm",
|
|
"description": "Generate content",
|
|
"version": "1.0.0",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, [
|
|
"skill", "info", "content_generator",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "content_generator" in result.stdout
|
|
|
|
def test_skill_load_local(self):
|
|
"""agentkit skill load <path> loads a YAML skill config"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
|
import yaml
|
|
yaml.dump({
|
|
"name": "test_skill",
|
|
"description": "A test skill",
|
|
"agent_type": "llm",
|
|
"task_mode": "llm_generate",
|
|
"prompt": {"system": "You are a test assistant"},
|
|
}, f)
|
|
f.flush()
|
|
result = runner.invoke(app, [
|
|
"skill", "load", f.name,
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "test_skill" in result.stdout or "loaded" in result.stdout.lower()
|
|
os.unlink(f.name)
|
|
|
|
def test_skill_load_invalid_file(self):
|
|
"""agentkit skill load with invalid file shows error"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, [
|
|
"skill", "load", "/nonexistent/file.yaml",
|
|
])
|
|
assert result.exit_code != 0 or "error" in result.stdout.lower() or "not found" in result.stdout.lower()
|
|
|
|
|
|
class TestInitCommand:
|
|
def test_init_non_interactive(self):
|
|
"""agentkit init --non-interactive generates config files"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
|
|
assert result.exit_code == 0
|
|
# Check generated files
|
|
assert os.path.exists(os.path.join(tmpdir, "agentkit.yaml"))
|
|
assert os.path.exists(os.path.join(tmpdir, ".env.example"))
|
|
assert os.path.exists(os.path.join(tmpdir, "docker-compose.yaml"))
|
|
assert os.path.exists(os.path.join(tmpdir, "skills"))
|
|
|
|
def test_init_agentkit_yaml_content(self):
|
|
"""agentkit init generates valid agentkit.yaml"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
|
|
import yaml
|
|
with open(os.path.join(tmpdir, "agentkit.yaml")) as f:
|
|
config = yaml.safe_load(f)
|
|
assert "server" in config
|
|
assert "llm" in config
|
|
assert config["server"]["port"] == 8001
|
|
|
|
def test_init_env_example_content(self):
|
|
"""agentkit init generates .env.example with API key placeholders"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
|
|
with open(os.path.join(tmpdir, ".env.example")) as f:
|
|
content = f.read()
|
|
assert "OPENAI_API_KEY" in content or "API_KEY" in content
|
|
|
|
def test_init_docker_compose_content(self):
|
|
"""agentkit init generates docker-compose.yaml with 3 services"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
|
|
import yaml
|
|
with open(os.path.join(tmpdir, "docker-compose.yaml")) as f:
|
|
compose = yaml.safe_load(f)
|
|
services = compose.get("services", {})
|
|
assert "agentkit" in services
|
|
assert "redis" in services
|
|
assert "postgres" in services
|
|
|
|
def test_init_existing_files_no_overwrite(self):
|
|
"""agentkit init does not overwrite existing files without --force"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create existing file
|
|
with open(os.path.join(tmpdir, "agentkit.yaml"), "w") as f:
|
|
f.write("existing")
|
|
result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
|
|
# Should either skip or prompt
|
|
with open(os.path.join(tmpdir, "agentkit.yaml")) as f:
|
|
content = f.read()
|
|
# File should still be "existing" (not overwritten) or overwritten with --force
|
|
assert content == "existing" or "agentkit" in content.lower()
|
|
|
|
|
|
class TestUsageCommand:
|
|
def test_usage_remote(self):
|
|
"""agentkit usage --server-url calls API"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"total_requests": 10,
|
|
"total_tokens": 5000,
|
|
"total_cost": 0.15,
|
|
}
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, [
|
|
"usage",
|
|
"--server-url", "http://localhost:8001",
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
def test_usage_format_json(self):
|
|
"""agentkit usage --format json outputs JSON"""
|
|
from agentkit.cli.main import app
|
|
with patch("httpx.Client.get") as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"total_requests": 10,
|
|
"total_tokens": 5000,
|
|
"total_cost": 0.15,
|
|
}
|
|
mock_get.return_value = mock_response
|
|
result = runner.invoke(app, [
|
|
"usage",
|
|
"--server-url", "http://localhost:8001",
|
|
"--format", "json",
|
|
])
|
|
assert result.exit_code == 0
|
|
|
|
def test_usage_no_server(self):
|
|
"""agentkit usage without --server-url shows local usage or error"""
|
|
from agentkit.cli.main import app
|
|
result = runner.invoke(app, ["usage"])
|
|
# Should either show local usage or error about missing server
|
|
# Either is acceptable
|
|
|
|
|
|
class TestPairCommand:
|
|
def test_pair_generates_api_key(self):
|
|
"""agentkit pair --name geo generates an API key"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
result = runner.invoke(app, [
|
|
"pair",
|
|
"--name", "geo-backend",
|
|
"--config-dir", tmpdir,
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "ak_live_" in result.stdout or "api_key" in result.stdout.lower()
|
|
|
|
def test_pair_saves_client_config(self):
|
|
"""agentkit pair saves client registration to clients.yaml"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
result = runner.invoke(app, [
|
|
"pair",
|
|
"--name", "geo-backend",
|
|
"--config-dir", tmpdir,
|
|
])
|
|
assert result.exit_code == 0
|
|
# Check clients.yaml was created
|
|
import yaml
|
|
clients_path = os.path.join(tmpdir, "clients.yaml")
|
|
assert os.path.exists(clients_path)
|
|
with open(clients_path) as f:
|
|
clients = yaml.safe_load(f)
|
|
assert "geo-backend" in clients
|
|
assert "api_key" in clients["geo-backend"]
|
|
assert clients["geo-backend"]["api_key"].startswith("ak_live_")
|
|
|
|
def test_pair_shows_connection_instructions(self):
|
|
"""agentkit pair shows how to connect"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
result = runner.invoke(app, [
|
|
"pair",
|
|
"--name", "geo-backend",
|
|
"--config-dir", tmpdir,
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "AGENTKIT_API_KEY" in result.stdout or "AGENTKIT_SERVER_URL" in result.stdout
|
|
|
|
def test_pair_rejects_duplicate_name(self):
|
|
"""agentkit pair rejects duplicate client name"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# First pair
|
|
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
|
|
# Second pair with same name
|
|
result = runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
|
|
assert result.exit_code != 0 or "already" in result.stdout.lower() or "exists" in result.stdout.lower()
|
|
|
|
def test_pair_with_custom_skills(self):
|
|
"""agentkit pair --skills-dir registers custom skills for client"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create a skills directory
|
|
skills_dir = os.path.join(tmpdir, "custom_skills")
|
|
os.makedirs(skills_dir)
|
|
import yaml
|
|
with open(os.path.join(skills_dir, "test_skill.yaml"), "w") as f:
|
|
yaml.dump({"name": "test_skill", "description": "Test", "agent_type": "assistant", "mode": "llm_generate", "prompt": "You are a test assistant"}, f)
|
|
|
|
result = runner.invoke(app, [
|
|
"pair",
|
|
"--name", "geo-backend",
|
|
"--skills-dir", skills_dir,
|
|
"--config-dir", tmpdir,
|
|
])
|
|
assert result.exit_code == 0
|
|
# Check client config includes skills_dir
|
|
clients_path = os.path.join(tmpdir, "clients.yaml")
|
|
with open(clients_path) as f:
|
|
clients = yaml.safe_load(f)
|
|
assert "skills_dir" in clients["geo-backend"]
|
|
|
|
def test_pair_list(self):
|
|
"""agentkit pair --list shows all paired clients"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Pair two clients
|
|
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
|
|
runner.invoke(app, ["pair", "--name", "another-app", "--config-dir", tmpdir])
|
|
# List
|
|
result = runner.invoke(app, ["pair", "--list", "--config-dir", tmpdir])
|
|
assert result.exit_code == 0
|
|
assert "geo-backend" in result.stdout
|
|
assert "another-app" in result.stdout
|
|
|
|
def test_pair_revoke(self):
|
|
"""agentkit pair --revoke removes a client"""
|
|
from agentkit.cli.main import app
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
|
|
result = runner.invoke(app, ["pair", "--revoke", "geo-backend", "--config-dir", tmpdir])
|
|
assert result.exit_code == 0
|
|
# Check client is removed
|
|
import yaml
|
|
clients_path = os.path.join(tmpdir, "clients.yaml")
|
|
with open(clients_path) as f:
|
|
clients = yaml.safe_load(f)
|
|
assert "geo-backend" not in clients
|