diff --git a/.gitignore b/.gitignore index f25e75c..8803202 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ __pycache__/ *.pyc .env +.venv/ +.pytest_cache/ +.coverage +htmlcov/ *.egg-info/ dist/ node_modules/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3b2c446 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..8cb33fb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.0.0 +pytest-cov>=5.0.0 +httpx>=0.27.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e79162f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +"""Shared pytest fixtures for the Agentic OS test suite. + +The application code (``server.py``) computes a number of module-level path +constants from ``BASE_DIR`` at import time. To keep tests hermetic — no writes +to the real repository — the fixtures below redirect every one of those +constants at a temporary directory and rebuild the minimal folder layout the +endpoints expect. +""" +import importlib +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +@pytest.fixture() +def server_module(tmp_path, monkeypatch): + """Import ``server`` with all filesystem paths redirected to ``tmp_path``. + + Returns the imported module with its path globals patched so tests can + exercise endpoints without touching the real project directories. + """ + server = importlib.import_module("server") + + base = tmp_path + for sub in ["data", "data/kanban", "brain", "brain/journal", "audit", + "skills", "scheduler/jobs", "registry", "standards", + "prompts", "backups", "agents"]: + (base / sub).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(server, "BASE_DIR", base) + monkeypatch.setattr(server, "KANBAN_DIR", base / "data" / "kanban") + monkeypatch.setattr(server, "GOALS_FILE", base / "data" / "goals.json") + monkeypatch.setattr(server, "JOURNAL_DIR", base / "brain" / "journal") + monkeypatch.setattr(server, "CHAT_HISTORY_FILE", + base / "data" / "chat-history.json") + monkeypatch.setattr(server, "_terminal_cwd", str(base)) + + return server + + +@pytest.fixture() +def client(server_module): + from fastapi.testclient import TestClient + + with TestClient(server_module.app) as c: + yield c diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..2a4ee5d --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,65 @@ +"""Unit tests for ``scheduler/scheduler.py``.""" +import importlib +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEDULER_DIR = REPO_ROOT / "scheduler" +if str(SCHEDULER_DIR) not in sys.path: + sys.path.insert(0, str(SCHEDULER_DIR)) + + +@pytest.fixture() +def scheduler_module(tmp_path, monkeypatch): + sched = importlib.import_module("scheduler") + base = tmp_path / "scheduler" + (base / "jobs").mkdir(parents=True) + (tmp_path / "audit").mkdir() + monkeypatch.setattr(sched, "BASE_DIR", base) + monkeypatch.setattr(sched, "JOBS_DIR", base / "jobs") + return sched + + +def test_run_skill_appends_audit(scheduler_module, capsys): + scheduler_module.run_skill("heartbeat") + audit_file = scheduler_module.BASE_DIR.parent / "audit" / "audit.log" + entry = json.loads(audit_file.read_text().strip()) + assert entry["action"] == "scheduler_run" + assert entry["skill"] == "heartbeat" + assert "timestamp" in entry + assert "Ran skill: heartbeat" in capsys.readouterr().out + + +def test_load_jobs_registers_enabled(scheduler_module): + (scheduler_module.JOBS_DIR / "hb.json").write_text(json.dumps({ + "id": "hb1", "name": "Heartbeat", "skill": "heartbeat", + "cron": "*/5 * * * *", "enabled": True, + })) + sched = scheduler_module.BackgroundScheduler() + scheduler_module.load_jobs(sched) + jobs = sched.get_jobs() + assert len(jobs) == 1 + assert jobs[0].id == "hb1" + assert jobs[0].name == "Heartbeat" + + +def test_load_jobs_skips_disabled(scheduler_module): + (scheduler_module.JOBS_DIR / "off.json").write_text(json.dumps({ + "id": "off1", "name": "Disabled", "skill": "x", + "cron": "0 0 * * *", "enabled": False, + })) + sched = scheduler_module.BackgroundScheduler() + scheduler_module.load_jobs(sched) + assert sched.get_jobs() == [] + + +def test_load_jobs_falls_back_to_name_as_id(scheduler_module): + (scheduler_module.JOBS_DIR / "noid.json").write_text(json.dumps({ + "name": "NoId", "skill": "x", "cron": "0 0 * * *", "enabled": True, + })) + sched = scheduler_module.BackgroundScheduler() + scheduler_module.load_jobs(sched) + assert sched.get_jobs()[0].id == "NoId" diff --git a/tests/test_server_endpoints.py b/tests/test_server_endpoints.py new file mode 100644 index 0000000..7df0ac6 --- /dev/null +++ b/tests/test_server_endpoints.py @@ -0,0 +1,359 @@ +"""Endpoint tests for ``server.py`` exercised through FastAPI's TestClient.""" +import json + +import pytest + + +# ─── Status / static ────────────────────────────────────────────── + +def test_status_ok(client, server_module, monkeypatch): + monkeypatch.setattr(server_module.shutil, "which", lambda name: None) + r = client.get("/api/status") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "healthy" + assert {a["name"] for a in body["agents"]} == {"opencode", "hermes", "gemini"} + assert body["skills_count"] == 0 + + +def test_index_without_dashboard(client): + r = client.get("/") + assert r.status_code == 200 + assert "Agentic OS" in r.text + + +def test_favicon_endpoints(client): + for path in ("/favicon.ico", "/favicon.svg"): + r = client.get(path) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("image/svg") + + +# ─── Brain ───────────────────────────────────────────────────────── + +def test_brain_list_and_get_and_update(client, server_module): + brain = server_module.BASE_DIR / "brain" + (brain / "memory.md").write_text("remember this") + + r = client.get("/api/brain") + assert r.json()["memory.md"] == "remember this" + + r = client.get("/api/brain/memory.md") + assert r.json() == {"name": "memory.md", "content": "remember this"} + + r = client.put("/api/brain/memory.md", json={"content": "new content"}) + assert r.status_code == 200 + assert (brain / "memory.md").read_text() == "new content" + + +def test_brain_get_missing_404(client): + assert client.get("/api/brain/ghost.md").status_code == 404 + + +# ─── Skills ──────────────────────────────────────────────────────── + +def _make_skill(server_module, name, skill_md="", eval_data=None, scores=None): + d = server_module.BASE_DIR / "skills" / name + d.mkdir(parents=True, exist_ok=True) + if skill_md: + (d / "SKILL.md").write_text(skill_md) + if eval_data is not None: + (d / "eval.json").write_text(json.dumps(eval_data)) + if scores is not None: + (d / "score-history.json").write_text(json.dumps(scores)) + return d + + +def test_list_skills(client, server_module): + _make_skill(server_module, "code-review", skill_md="Review code", + eval_data={"criteria": ["clarity"]}, scores=[{"score": 8}]) + _make_skill(server_module, "_template", skill_md="ignored") + skills = client.get("/api/skills").json() + names = [s["name"] for s in skills] + assert "code-review" in names + assert "_template" not in names + cr = next(s for s in skills if s["name"] == "code-review") + assert cr["eval_criteria"] == ["clarity"] + assert cr["scores"] == [{"score": 8}] + + +def test_get_skill_and_missing(client, server_module): + _make_skill(server_module, "brainstorming", skill_md="Ideas", + eval_data={"criteria": []}, scores=[{"score": 5}]) + r = client.get("/api/skills/brainstorming") + assert r.json()["skill"] == "Ideas" + assert client.get("/api/skills/nope").status_code == 404 + + +def test_get_skill_eval(client, server_module): + _make_skill(server_module, "tdd-cycle", scores=[{"score": 7}]) + assert client.get("/api/skills/tdd-cycle/eval").json() == {"scores": [{"score": 7}]} + assert client.get("/api/skills/other/eval").json() == {"scores": []} + + +@pytest.mark.parametrize("name,expected_agent", [ + ("devops-audit", "opencode"), + ("research-synthesis", "gemini"), + ("generic-skill", "opencode"), +]) +def test_run_skill_auto_routes(client, server_module, monkeypatch, name, expected_agent): + _make_skill(server_module, name, skill_md="do stuff") + captured = {} + + def fake_exec(agent, prompt): + captured["agent"] = agent + return "done" + + monkeypatch.setattr(server_module, "execute_agent", fake_exec) + r = client.post(f"/api/skills/{name}/run", json={"input": "go", "agent": "auto"}) + assert r.status_code == 200 + assert r.json()["agent"] == expected_agent + assert captured["agent"] == expected_agent + + +def test_run_skill_uses_skill_md_primary(client, server_module, monkeypatch): + _make_skill(server_module, "meeting-notes", skill_md="Primary: hermes\nrest") + monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok") + r = client.post("/api/skills/meeting-notes/run", json={"agent": "auto"}) + assert r.json()["agent"] == "hermes" + + +def test_run_skill_missing_404(client, server_module, monkeypatch): + monkeypatch.setattr(server_module, "execute_agent", lambda a, p: "ok") + assert client.post("/api/skills/ghost/run", json={}).status_code == 404 + + +# ─── Scheduler jobs ──────────────────────────────────────────────── + +def test_scheduler_job_crud(client, server_module): + assert client.get("/api/scheduler/jobs").json() == [] + created = client.post("/api/scheduler/jobs", json={ + "name": "nightly audit", "skill": "devops-audit", "cron": "0 0 * * *", + }).json() + assert created["name"] == "nightly audit" + jobs = client.get("/api/scheduler/jobs").json() + assert len(jobs) == 1 + # file name derives from the job name with spaces replaced. + assert (server_module.BASE_DIR / "scheduler" / "jobs" / "nightly_audit.json").exists() + + assert client.delete(f"/api/scheduler/jobs/{created['id']}").json() == {"status": "deleted"} + assert client.get("/api/scheduler/jobs").json() == [] + + +def test_delete_missing_job_404(client): + assert client.delete("/api/scheduler/jobs/deadbeef").status_code == 404 + + +# ─── Audit ───────────────────────────────────────────────────────── + +def test_audit_empty_and_limit(client, server_module): + assert client.get("/api/audit").json() == {"entries": []} + for i in range(5): + server_module.append_audit({"action": "act", "n": i}) + entries = client.get("/api/audit?limit=2").json()["entries"] + assert len(entries) == 2 + assert entries[-1]["n"] == 4 + + +# ─── Cost ────────────────────────────────────────────────────────── + +def test_cost_empty_then_record(client): + assert client.get("/api/cost").json()["entries"] == [] + client.post("/api/cost/record", json={"agent": "gemini", "tokens": 10, "cost": 0.0, "model": "flash"}) + data = client.get("/api/cost").json() + assert data["entries"][0]["agent"] == "gemini" + assert data["entries"][0]["tokens"] == 10 + + +# ─── Plugins ─────────────────────────────────────────────────────── + +def test_plugins_install_flow(client): + assert client.get("/api/plugins").json() == {"plugins": []} + assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "installed" + assert client.post("/api/plugins/install", json={"name": "cool-plugin"}).json()["status"] == "already_installed" + assert client.post("/api/plugins/install", json={"name": ""}).status_code == 400 + assert client.get("/api/plugins").json()["plugins"][0]["name"] == "cool-plugin" + + +# ─── Settings ────────────────────────────────────────────────────── + +def test_settings_get_empty_and_update_merges(client, server_module): + assert client.get("/api/settings").json() == {} + client.put("/api/settings", json={"settings": {"theme": "dark"}}) + client.put("/api/settings", json={"settings": {"port": 9000}}) + result = client.get("/api/settings").json() + assert result == {"theme": "dark", "port": 9000} + + +# ─── Standards ───────────────────────────────────────────────────── + +def test_standards_list_and_discover(client, server_module): + std = server_module.BASE_DIR / "standards" + (std / "naming.md").write_text("use snake_case") + (std / "index.yml").write_text("standards: [naming]") + body = client.get("/api/standards").json() + assert any(s["name"] == "naming" for s in body["standards"]) + assert "snake_case" in body["standards"][0]["content"] + assert client.post("/api/standards/discover").json()["status"] == "discovery_started" + + +# ─── Prompts ─────────────────────────────────────────────────────── + +def test_prompts_list(client, server_module): + (server_module.BASE_DIR / "prompts" / "code-review.md").write_text("template body") + assert client.get("/api/prompts").json()["code-review"] == "template body" + + +# ─── Backups ─────────────────────────────────────────────────────── + +def test_backup_create_list_restore(client, server_module): + (server_module.BASE_DIR / "brain" / "memory.md").write_text("data") + created = client.post("/api/backup").json() + assert created["status"] == "ok" + listed = client.get("/api/backups").json() + assert any(b["name"] == created["file"] for b in listed) + assert client.post("/api/backup/restore", json={"file": created["file"]}).json()["status"] == "restored" + + +def test_restore_missing_backup_404(client): + assert client.post("/api/backup/restore", json={"file": "nope.tar.gz"}).status_code == 404 + + +# ─── Chat ────────────────────────────────────────────────────────── + +def test_chat_invalid_agent(client): + assert client.post("/api/chat", json={"agent": "bogus", "message": "hi"}).status_code == 400 + + +def test_chat_records_history(client, server_module, monkeypatch): + monkeypatch.setattr(server_module, "execute_agent", lambda a, m: "hello from agent") + r = client.post("/api/chat", json={"agent": "Gemini", "message": "hi"}) + assert r.status_code == 200 + assert r.json()["response"]["content"] == "hello from agent" + history = client.get("/api/chat/history").json()["messages"] + assert history[0]["role"] == "user" + assert history[1]["role"] == "assistant" + + +# ─── Terminal ────────────────────────────────────────────────────── + +def test_terminal_session_and_blank_command(client, server_module): + assert client.get("/api/terminal/session").json()["cwd"] + r = client.post("/api/terminal/run", json={"command": " "}) + assert r.json()["returncode"] == 0 + + +def test_terminal_cd_invalid_dir(client): + r = client.post("/api/terminal/run", json={"command": "cd /this/does/not/exist"}) + assert r.json()["returncode"] == 1 + assert "no such directory" in r.json()["stderr"] + + +def test_terminal_run_echo(client): + r = client.post("/api/terminal/run", json={"command": "echo hello-term"}) + assert r.json()["returncode"] == 0 + assert "hello-term" in r.json()["stdout"] + + +# ─── Goals ───────────────────────────────────────────────────────── + +def test_goals_crud(client, server_module): + assert client.get("/api/goals").json() == {"goals": []} + created = client.post("/api/goals", json={"title": "Ship v1", "description": "launch"}).json() + gid = created["id"] + assert created["status"] == "active" + updated = client.put(f"/api/goals/{gid}", json={"progress": 50}).json() + assert updated["progress"] == 50 + assert client.put("/api/goals/missing", json={"progress": 1}).status_code == 404 + assert client.delete(f"/api/goals/{gid}").json() == {"status": "deleted"} + assert client.get("/api/goals").json() == {"goals": []} + + +def test_goal_creation_syncs_active_projects(client, server_module): + active = server_module.BASE_DIR / "brain" / "active-projects.md" + active.write_text("# Projects\n") + client.post("/api/goals", json={"title": "Docs", "description": "write docs"}) + assert "Docs" in active.read_text() + + +# ─── Journal ─────────────────────────────────────────────────────── + +def test_journal_save_get_list_search(client, server_module): + assert client.get("/api/journal/entries").json() == {"entries": []} + client.put("/api/journal/entries/2026-07-08", json={"content": "Today I tested code"}) + assert client.get("/api/journal/entries/2026-07-08").json()["content"] == "Today I tested code" + entries = client.get("/api/journal/entries").json()["entries"] + assert entries[0]["date"] == "2026-07-08" + found = client.get("/api/journal/search?q=tested").json()["results"] + assert found[0]["date"] == "2026-07-08" + assert client.get("/api/journal/search?q=").json() == {"results": []} + + +# ─── Agent health ────────────────────────────────────────────────── + +def test_agent_health(client, server_module, monkeypatch): + monkeypatch.setattr(server_module.shutil, "which", lambda n: None) + body = client.get("/api/agents/health").json() + assert len(body["agents"]) == 3 + assert body["agents"][0]["success_rate"] == 100 + + +def test_agent_stats_valid_and_invalid(client, server_module, monkeypatch): + monkeypatch.setattr(server_module.shutil, "which", lambda n: None) + assert client.get("/api/agents/opencode/stats").json()["name"] == "opencode" + assert client.get("/api/agents/nope/stats").status_code == 400 + + +def test_agent_health_refresh(client, server_module, monkeypatch): + monkeypatch.setattr(server_module.shutil, "which", lambda n: None) + assert len(client.post("/api/agents/health/refresh").json()["agents"]) == 3 + + +# ─── Smart router ────────────────────────────────────────────────── + +def test_router_suggest_high_confidence(client): + body = client.post("/api/router/suggest", json={"task": "deploy docker infra with terraform"}).json() + assert body["suggested_agent"] == "opencode" + assert body["confidence"] == "high" + + +def test_router_suggest_low_confidence(client): + body = client.post("/api/router/suggest", json={"task": "xyzzy"}).json() + assert body["confidence"] == "low" + + +def test_router_route_valid_and_invalid(client): + assert client.post("/api/router/route", json={"task": "t", "agent": "Hermes"}).json()["status"] == "routed" + assert client.post("/api/router/route", json={"task": "t", "agent": "bad"}).json()["status"] == "error" + + +# ─── Analytics ───────────────────────────────────────────────────── + +def test_skill_analytics(client, server_module): + _make_skill(server_module, "code-review", scores=[{"score": 5}, {"score": 8}]) + _make_skill(server_module, "brainstorming", scores=[]) + body = client.get("/api/analytics/skills").json()["skills"] + cr = next(s for s in body if s["name"] == "code-review") + assert cr["total_runs"] == 2 + assert cr["avg_score"] == 6.5 + assert cr["trend"] == "up" + + +def test_trend_analytics(client, server_module): + _make_skill(server_module, "tdd-cycle", scores=[{"score": 3, "date": "d1"}]) + body = client.get("/api/analytics/trends").json()["trends"] + assert body[0]["name"] == "tdd-cycle" + assert body[0]["scores"] == [3] + + +# ─── Session replay ──────────────────────────────────────────────── + +def test_sessions_list_empty(client, server_module, monkeypatch, tmp_path): + monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path)) + assert client.get("/api/sessions/list").json() == {"sessions": []} + + +def test_session_replay_not_found(client, server_module, monkeypatch, tmp_path): + monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path)) + body = client.get("/api/sessions/some-id/replay").json() + assert body["content"] == "Session log not found" diff --git a/tests/test_server_helpers.py b/tests/test_server_helpers.py new file mode 100644 index 0000000..c2e1cd6 --- /dev/null +++ b/tests/test_server_helpers.py @@ -0,0 +1,147 @@ +"""Unit tests for the pure helper functions in ``server.py``.""" +import json + +import pytest + + +def test_read_file_missing_returns_empty(server_module, tmp_path): + assert server_module.read_file(tmp_path / "nope.txt") == "" + + +def test_read_write_file_roundtrip(server_module, tmp_path): + target = tmp_path / "note.txt" + assert server_module.write_file(target, "hello") is True + assert server_module.read_file(target) == "hello" + + +def test_list_dir_missing_returns_empty(server_module, tmp_path): + assert server_module.list_dir(tmp_path / "absent") == [] + + +def test_list_dir_skips_hidden_and_sorts(server_module, tmp_path): + d = tmp_path / "things" + d.mkdir() + (d / "b.txt").write_text("") + (d / "a.txt").write_text("") + (d / ".hidden").write_text("") + assert server_module.list_dir(d) == ["a.txt", "b.txt"] + + +def test_get_timestamp_is_iso_utc(server_module): + ts = server_module.get_timestamp() + # datetime.fromisoformat round-trips a valid ISO 8601 string. + from datetime import datetime + + parsed = datetime.fromisoformat(ts) + assert parsed.tzinfo is not None + + +def test_append_audit_writes_entry(server_module): + server_module.append_audit({"action": "unit_test"}) + audit_file = server_module.BASE_DIR / "audit" / "audit.log" + lines = audit_file.read_text().strip().splitlines() + assert len(lines) == 1 + entry = json.loads(lines[0]) + assert entry["action"] == "unit_test" + assert "timestamp" in entry + assert len(entry["id"]) == 8 + + +def test_get_cors_origins_defaults(server_module, monkeypatch): + monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False) + origins = server_module.get_cors_origins() + assert "http://localhost:8080" in origins + assert "http://127.0.0.1:8080" in origins + + +def test_get_cors_origins_reads_port_from_settings(server_module, monkeypatch): + monkeypatch.delenv("AGENTIC_OS_CORS_ORIGINS", raising=False) + settings = server_module.BASE_DIR / "data" / "settings.json" + settings.write_text(json.dumps({"dashboard": {"port": 9000}})) + origins = server_module.get_cors_origins() + assert "http://localhost:9000" in origins + assert "http://127.0.0.1:9000" in origins + + +def test_get_cors_origins_includes_env_extras(server_module, monkeypatch): + monkeypatch.setenv("AGENTIC_OS_CORS_ORIGINS", "https://a.example, https://b.example ") + origins = server_module.get_cors_origins() + assert "https://a.example" in origins + assert "https://b.example" in origins + + +def test_get_cors_origins_bad_settings_falls_back(server_module): + settings = server_module.BASE_DIR / "data" / "settings.json" + settings.write_text("{ not valid json") + origins = server_module.get_cors_origins() + assert "http://localhost:8080" in origins + + +@pytest.mark.parametrize("which_result,expected", [(None, "offline"), ("/usr/bin/opencode", "online")]) +def test_check_agent_opencode(server_module, monkeypatch, which_result, expected): + monkeypatch.setattr(server_module.shutil, "which", lambda name: which_result) + assert server_module.check_agent("opencode") == {"name": "opencode", "status": expected} + + +def test_check_agent_unknown_is_offline(server_module): + assert server_module.check_agent("mystery")["status"] == "offline" + + +def test_check_agent_gemini_warning_when_not_logged_in(server_module, monkeypatch, tmp_path): + monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini") + monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path)) + # No oauth creds file -> installed but not logged in -> warning. + assert server_module.check_agent("gemini")["status"] == "warning" + + +def test_check_agent_gemini_online_when_logged_in(server_module, monkeypatch, tmp_path): + monkeypatch.setattr(server_module.shutil, "which", lambda name: "/usr/bin/gemini") + monkeypatch.setattr(server_module.Path, "home", staticmethod(lambda: tmp_path)) + creds = tmp_path / ".gemini" / "oauth_creds.json" + creds.parent.mkdir(parents=True) + creds.write_text('{"token": "ya29.abc"}') + assert server_module.check_agent("gemini")["status"] == "online" + + +def test_clean_hermes_output_empty(server_module): + assert server_module.clean_hermes_output("") == "" + + +def test_clean_hermes_output_extracts_box_content(server_module): + raw = "Query: hi\n╭─ box\nHello there\nSecond line\n╰─ end\nDuration: 1s" + assert server_module.clean_hermes_output(raw) == "Hello there\nSecond line" + + +def test_clean_hermes_output_fallback_without_box(server_module): + raw = "Query: hi\nInitializing...\nActual answer here" + assert "Actual answer here" in server_module.clean_hermes_output(raw) + + +def test_kanban_task_path_rejects_bad_id(server_module): + with pytest.raises(server_module.HTTPException): + server_module.kanban_task_path("../../etc/passwd") + + +def test_kanban_task_path_rejects_empty(server_module): + with pytest.raises(server_module.HTTPException): + server_module.kanban_task_path("") + + +def test_kanban_task_path_accepts_valid_id(server_module): + path = server_module.kanban_task_path("abc123") + assert path.name == "abc123.json" + assert path.parent == server_module.KANBAN_DIR.resolve() + + +def test_load_save_chat_history_roundtrip(server_module): + assert server_module.load_chat_history() == {"messages": []} + server_module.save_chat_message({"content": "hi"}) + assert server_module.load_chat_history()["messages"][-1]["content"] == "hi" + + +def test_save_chat_message_caps_at_200(server_module): + for i in range(210): + server_module.save_chat_message({"content": str(i)}) + history = server_module.load_chat_history() + assert len(history["messages"]) == 200 + assert history["messages"][-1]["content"] == "209" diff --git a/tests/test_server_kanban.py b/tests/test_server_kanban.py new file mode 100644 index 0000000..8c9c077 --- /dev/null +++ b/tests/test_server_kanban.py @@ -0,0 +1,158 @@ +"""Tests for the Kanban board endpoints and autonomous dispatch logic.""" +import json + +import pytest + + +@pytest.fixture() +def sync_threads(server_module, monkeypatch): + """Run ``threading.Thread`` targets synchronously so dispatch is deterministic.""" + class _SyncThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + + def start(self): + if self._target: + self._target(*self._args, **self._kwargs) + + monkeypatch.setattr(server_module.threading, "Thread", _SyncThread) + return server_module + + +def _create(client, **kw): + payload = {"title": "T", "body": "", "status": "triage", + "priority": "medium", "assignee": ""} + payload.update(kw) + return client.post("/api/kanban/tasks", json=payload).json() + + +def test_board_empty(client): + body = client.get("/api/kanban/board").json() + assert body["total"] == 0 + assert set(body["columns"]) == {"triage", "todo", "ready", "in_progress", "blocked", "done"} + + +def test_create_and_get_task(client): + task = _create(client, title="Write tests", assignee="") + assert task["title"] == "Write tests" + assert task["status"] == "triage" + fetched = client.get(f"/api/kanban/tasks/{task['id']}").json() + assert fetched["id"] == task["id"] + + +def test_get_missing_task_404(client): + assert client.get("/api/kanban/tasks/abc123").status_code == 404 + + +def test_board_groups_by_status(client): + _create(client, status="todo") + _create(client, status="done") + board = client.get("/api/kanban/board").json() + assert len(board["columns"]["todo"]) == 1 + assert len(board["columns"]["done"]) == 1 + assert board["total"] == 2 + # filter by status query + filtered = client.get("/api/kanban/board?status=todo").json() + assert filtered["total"] == 1 + + +def test_update_task(client): + task = _create(client) + updated = client.patch(f"/api/kanban/tasks/{task['id']}", + json={"title": "renamed", "priority": "high"}).json() + assert updated["title"] == "renamed" + assert updated["priority"] == "high" + + +def test_update_missing_404(client): + assert client.patch("/api/kanban/tasks/abc123", json={"title": "x"}).status_code == 404 + + +def test_complete_block_unblock(client): + task = _create(client) + tid = task["id"] + assert client.post(f"/api/kanban/tasks/{tid}/complete", json={"summary": "done!"}).json()["status"] == "done" + assert client.post(f"/api/kanban/tasks/{tid}/block", json={"reason": "stuck"}).json()["status"] == "blocked" + unblocked = client.post(f"/api/kanban/tasks/{tid}/unblock").json() + assert unblocked["status"] == "ready" + assert unblocked["block_reason"] == "" + + +def test_comments(client): + task = _create(client) + updated = client.post(f"/api/kanban/tasks/{task['id']}/comments", json={"message": "hi there"}).json() + assert updated["comments"][0]["message"] == "hi there" + + +def test_links_add_and_remove(client): + parent = _create(client) + child = _create(client) + r = client.post("/api/kanban/links", json={"parent_id": parent["id"], "child_id": child["id"]}) + assert r.json() == {"status": "linked"} + linked = client.get(f"/api/kanban/tasks/{parent['id']}").json() + assert {"parent": parent["id"], "child": child["id"]} in linked["links"] + r = client.delete(f"/api/kanban/links?parent_id={parent['id']}&child_id={child['id']}") + assert r.json() == {"status": "unlinked"} + unlinked = client.get(f"/api/kanban/tasks/{parent['id']}").json() + assert unlinked["links"] == [] + + +def test_link_missing_task_404(client): + parent = _create(client) + assert client.post("/api/kanban/links", + json={"parent_id": parent["id"], "child_id": "abcdef"}).status_code == 404 + + +def test_specify_moves_triage_to_todo(client): + task = _create(client, status="triage") + assert client.post(f"/api/kanban/tasks/{task['id']}/specify").json()["status"] == "todo" + + +def test_decompose_creates_children(client): + task = _create(client, body="- first subtask\n- second subtask\n\n") + body = client.post(f"/api/kanban/tasks/{task['id']}/decompose").json() + assert body["parent"] == task["id"] + assert len(body["children"]) == 2 + titles = [c["title"] for c in body["children"]] + assert "first subtask" in titles + + +def test_dispatch_requires_valid_assignee(client): + task = _create(client, assignee="") + assert client.post(f"/api/kanban/tasks/{task['id']}/dispatch").status_code == 400 + + +def test_create_with_agent_assignee_dispatches(client, sync_threads, monkeypatch): + monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "agent finished the job") + task = _create(client, title="Do work", assignee="opencode") + # With synchronous dispatch, the task runs to completion immediately. + assert task["status"] == "done" + assert task["summary"].startswith("agent finished") + assert any("opencode" in c["message"] for c in task["comments"]) + + +def test_dispatch_failure_blocks_task(client, sync_threads, monkeypatch): + monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "⚠ Agent not installed") + task = _create(client, title="Do work", assignee="hermes") + assert task["status"] == "blocked" + assert "not installed" in task["block_reason"] + + +def test_bulk_dispatch_endpoint(client, sync_threads, monkeypatch): + monkeypatch.setattr(sync_threads, "execute_agent", lambda agent, prompt: "ok done") + + # Write task files directly so they start eligible (todo/ready with an agent + # assignee) — going through the create endpoint would auto-dispatch them. + def _seed(tid, status, assignee): + sync_threads.save_kanban_task({ + "id": tid, "title": "t", "body": "", "status": status, + "priority": "medium", "assignee": assignee, "comments": [], "links": [], + }) + + _seed("aaaaaa", "todo", "gemini") + _seed("bbbbbb", "ready", "opencode") + _seed("cccccc", "triage", "") # not eligible + body = client.post("/api/kanban/dispatch").json() + assert len(body["dispatched"]) == 2