Skip to content

Commit 44e854e

Browse files
authored
test(F1): cover CLI, MCP server, and structured logging (#34)
* test(F1): cover CLI, MCP server, and structured logging Close the three biggest coverage gaps from the audit (F1), all additive: - tests/cli/test_cli.py (17): Typer CliRunner over the read-only/no-daemon commands -- init (create + idempotent), the 'run hive init first' guards, status (empty), spawn (unknown profile + success->status), kill/nudge agent-not-found, tasks/notes/runs empty listings, models without a hive, and no-args help. - tests/mcp/test_server.py (9): drive HiveMCPServer._handle_message directly (no stdio) -- initialize capabilities, tools/list schema, tools/call dispatch (spawn->status), error wrapping on missing args, unknown method -> None; handle_tool unknown-tool / unknown-profile / agent-not-found. - tests/logging/test_writer_reader.py (9): LogWriter->LogReader round-trip for all record types, get_summary aggregation, run-dir layout, missing-run graceful handling, and a 50-thread concurrent-append smoke test. 996 tests pass; ruff/format/mypy/mkdocs clean. * test(F1): address review — cwd-independent MCP spawn test + lock docstring - Greptile: the MCP spawn test resolved profiles via the ambient cwd. The server fixture now chdir's into a tmp dir with its own profiles/coder.yaml, so default_profiles_dir() is deterministic regardless of where pytest runs. - Greptile: correct the logging concurrency-test docstring -- the lock is module-level in writer.py, not an instance lock 'in the writer'. (Did not add __init__.py to tests/cli|logging: tests/logging/__init__.py shadows the stdlib logging module and breaks collection; several existing test dirs -- tools/routing/stt/triggers -- also omit it. Unique test basenames keep pytest's prepend import mode working.)
1 parent 54fbd13 commit 44e854e

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

tests/cli/test_cli.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""CLI smoke/behavior tests via Typer's CliRunner (F1 coverage).
2+
3+
Covers the read-only / no-daemon commands: argument parsing, exit codes, and
4+
error paths. The async TUI/daemon commands (start, watch, orchestrate, agent
5+
chat) are out of scope here.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
import pytest
13+
from typer.testing import CliRunner
14+
15+
from hive.cli.main import app
16+
17+
runner = CliRunner()
18+
19+
20+
@pytest.fixture
21+
def in_tmp_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
22+
"""Run each command inside an isolated cwd (the CLI uses Path.cwd()/.hive)."""
23+
monkeypatch.chdir(tmp_path)
24+
return tmp_path
25+
26+
27+
def _init(in_tmp_cwd: Path) -> None:
28+
result = runner.invoke(app, ["init"])
29+
assert result.exit_code == 0
30+
31+
32+
def _write_profile(cwd: Path, name: str = "coder") -> None:
33+
"""Make `name`.yaml available to `hive spawn`, which reads cwd/profiles."""
34+
dest_dir = cwd / "profiles"
35+
dest_dir.mkdir(exist_ok=True)
36+
(dest_dir / f"{name}.yaml").write_text(
37+
f'name: {name}\nrole: "Test agent"\nmodel: claude-haiku-4-5\nautonomy: high\nmax_steps: 5\n'
38+
)
39+
40+
41+
class TestInit:
42+
def test_init_creates_hive(self, in_tmp_cwd: Path) -> None:
43+
result = runner.invoke(app, ["init"])
44+
assert result.exit_code == 0
45+
assert (in_tmp_cwd / ".hive").is_dir()
46+
assert (in_tmp_cwd / ".hive" / "hive.db").exists()
47+
48+
def test_init_idempotent(self, in_tmp_cwd: Path) -> None:
49+
runner.invoke(app, ["init"])
50+
result = runner.invoke(app, ["init"])
51+
assert result.exit_code == 0
52+
assert "already initialized" in result.output.lower()
53+
54+
55+
class TestGuardsRequireInit:
56+
@pytest.mark.parametrize(
57+
"args",
58+
[["status"], ["spawn", "coder"], ["kill", "x"], ["nudge", "x", "hi"], ["tasks"]],
59+
)
60+
def test_commands_exit_1_without_hive(self, in_tmp_cwd: Path, args: list[str]) -> None:
61+
result = runner.invoke(app, args)
62+
assert result.exit_code == 1
63+
assert "init" in result.output.lower()
64+
65+
66+
class TestStatus:
67+
def test_status_empty(self, in_tmp_cwd: Path) -> None:
68+
_init(in_tmp_cwd)
69+
result = runner.invoke(app, ["status"])
70+
assert result.exit_code == 0
71+
assert "No agents" in result.output
72+
73+
74+
class TestSpawn:
75+
def test_spawn_unknown_profile(self, in_tmp_cwd: Path) -> None:
76+
_init(in_tmp_cwd)
77+
result = runner.invoke(app, ["spawn", "ghost"])
78+
assert result.exit_code == 1
79+
assert "not found" in result.output.lower()
80+
81+
def test_spawn_success_then_status_lists_it(self, in_tmp_cwd: Path) -> None:
82+
_init(in_tmp_cwd)
83+
_write_profile(in_tmp_cwd, "coder")
84+
result = runner.invoke(app, ["spawn", "coder"])
85+
assert result.exit_code == 0
86+
assert "Spawned" in result.output
87+
88+
status = runner.invoke(app, ["status"])
89+
assert "coder" in status.output
90+
91+
92+
class TestAgentLookupErrors:
93+
def test_kill_unknown_agent(self, in_tmp_cwd: Path) -> None:
94+
_init(in_tmp_cwd)
95+
result = runner.invoke(app, ["kill", "nobody"])
96+
assert result.exit_code == 1
97+
assert "not found" in result.output.lower()
98+
99+
def test_nudge_unknown_agent(self, in_tmp_cwd: Path) -> None:
100+
_init(in_tmp_cwd)
101+
result = runner.invoke(app, ["nudge", "nobody", "do the thing"])
102+
assert result.exit_code == 1
103+
assert "not found" in result.output.lower()
104+
105+
106+
class TestReadOnlyListings:
107+
def test_tasks_empty(self, in_tmp_cwd: Path) -> None:
108+
_init(in_tmp_cwd)
109+
result = runner.invoke(app, ["tasks"])
110+
assert result.exit_code == 0
111+
assert "No pending tasks" in result.output
112+
113+
def test_notes_empty(self, in_tmp_cwd: Path) -> None:
114+
_init(in_tmp_cwd)
115+
result = runner.invoke(app, ["notes"])
116+
assert result.exit_code == 0
117+
assert "No notes" in result.output
118+
119+
def test_runs_empty(self, in_tmp_cwd: Path) -> None:
120+
result = runner.invoke(app, ["runs"])
121+
assert result.exit_code == 0
122+
assert "No runs" in result.output
123+
124+
def test_models_runs_without_hive(self, in_tmp_cwd: Path) -> None:
125+
# `models` inspects providers; it must not require an initialized hive.
126+
result = runner.invoke(app, ["models"])
127+
assert result.exit_code == 0
128+
129+
130+
class TestHelp:
131+
def test_no_args_shows_help(self) -> None:
132+
result = runner.invoke(app, [])
133+
# no_args_is_help=True -> usage shown, non-crashing exit.
134+
assert "Usage" in result.output or "Commands" in result.output
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Round-trip tests for the structured log writer/reader (F1 coverage)."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from pathlib import Path
7+
8+
from hive.logging.models import (
9+
CycleLog,
10+
DecisionLog,
11+
GoalLog,
12+
SufferingLog,
13+
ToolLog,
14+
)
15+
from hive.logging.reader import LogReader
16+
from hive.logging.writer import LogWriter
17+
18+
19+
def _start_run(writer: LogWriter) -> str:
20+
return writer.start_run(
21+
heartbeat=10,
22+
profiles=["coder"],
23+
agents=["a1"],
24+
tools=["world_query"],
25+
)
26+
27+
28+
class TestWriterReaderRoundTrip:
29+
def test_start_run_creates_layout_and_run_json(self, tmp_path: Path) -> None:
30+
writer = LogWriter(tmp_path)
31+
run_id = _start_run(writer)
32+
33+
run_dir = tmp_path / "runs" / run_id
34+
assert (run_dir / "run.json").exists()
35+
assert (run_dir / "cycles").is_dir()
36+
assert (run_dir / "agents").is_dir()
37+
38+
reader = LogReader(tmp_path)
39+
run = reader.get_run(run_id)
40+
assert run is not None
41+
assert run.heartbeat == 10
42+
assert run.profiles == ["coder"]
43+
assert run.agents_spawned == ["a1"]
44+
45+
def test_all_record_types_round_trip(self, tmp_path: Path) -> None:
46+
writer = LogWriter(tmp_path)
47+
run_id = _start_run(writer)
48+
49+
writer.log_cycle(CycleLog(run_id=run_id, cycle=1, agents_active=1))
50+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g1", event="generated", objective="ship"))
51+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g1", event="completed", steps_done=3))
52+
writer.log_decision(
53+
DecisionLog(agent_id="a1", decision_type="goal", input_tokens=10, output_tokens=5)
54+
)
55+
writer.log_tool(ToolLog(agent_id="a1", tool_name="world_query", success=True))
56+
writer.log_suffering(SufferingLog(agent_id="a1", cycle=1, cumulative_load=0.2))
57+
58+
reader = LogReader(tmp_path)
59+
cycles = reader.get_cycles(run_id)
60+
assert len(cycles) == 1 and cycles[0].agents_active == 1
61+
62+
goals = reader.get_agent_goals(run_id, "a1")
63+
assert {g.event for g in goals} == {"generated", "completed"}
64+
65+
decisions = reader.get_agent_decisions(run_id, "a1")
66+
assert len(decisions) == 1 and decisions[0].input_tokens == 10
67+
68+
tools = reader.get_agent_tools(run_id, "a1")
69+
assert len(tools) == 1 and tools[0].tool_name == "world_query"
70+
71+
suffering = reader.get_agent_suffering(run_id, "a1")
72+
assert len(suffering) == 1 and suffering[0].cumulative_load == 0.2
73+
74+
assert reader.get_agent_ids(run_id) == ["a1"]
75+
76+
def test_get_summary_aggregates(self, tmp_path: Path) -> None:
77+
writer = LogWriter(tmp_path)
78+
run_id = _start_run(writer)
79+
80+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g1", event="generated"))
81+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g1", event="completed"))
82+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g2", event="generated"))
83+
writer.log_goal(GoalLog(agent_id="a1", goal_id="g2", event="abandoned"))
84+
writer.log_tool(ToolLog(agent_id="a1", tool_name="t", success=True))
85+
writer.log_decision(
86+
DecisionLog(
87+
agent_id="a1",
88+
decision_type="goal",
89+
input_tokens=100,
90+
output_tokens=50,
91+
cost_usd=0.01,
92+
)
93+
)
94+
95+
summary = LogReader(tmp_path).get_summary(run_id)
96+
assert summary["agents"] == 1
97+
assert summary["goals_generated"] == 2
98+
assert summary["goals_completed"] == 1
99+
assert summary["goals_abandoned"] == 1
100+
assert summary["tool_calls"] == 1
101+
assert summary["total_tokens"] == 150
102+
assert summary["total_cost_usd"] == 0.01
103+
104+
105+
class TestReaderMissingData:
106+
def test_list_runs_empty_when_no_dir(self, tmp_path: Path) -> None:
107+
assert LogReader(tmp_path).list_runs() == []
108+
109+
def test_get_missing_run_returns_none(self, tmp_path: Path) -> None:
110+
LogWriter(tmp_path) # creates runs/ dir but no run
111+
assert LogReader(tmp_path).get_run("run-does-not-exist") is None
112+
113+
def test_summary_of_missing_run_is_empty(self, tmp_path: Path) -> None:
114+
assert LogReader(tmp_path).get_summary("nope") == {}
115+
116+
def test_queries_on_missing_run_return_empty_lists(self, tmp_path: Path) -> None:
117+
reader = LogReader(tmp_path)
118+
assert reader.get_cycles("nope") == []
119+
assert reader.get_agent_goals("nope", "a1") == []
120+
assert reader.get_agent_ids("nope") == []
121+
122+
def test_list_runs_after_start(self, tmp_path: Path) -> None:
123+
writer = LogWriter(tmp_path)
124+
run_id = _start_run(writer)
125+
runs = LogReader(tmp_path).list_runs()
126+
assert [r.run_id for r in runs] == [run_id]
127+
128+
129+
class TestConcurrentWrites:
130+
def test_concurrent_tool_logs_all_land(self, tmp_path: Path) -> None:
131+
"""The module-level threading.Lock guarding writes keeps concurrent appends intact."""
132+
writer = LogWriter(tmp_path)
133+
run_id = _start_run(writer)
134+
135+
def write(i: int) -> None:
136+
writer.log_tool(ToolLog(agent_id="a1", tool_name=f"tool-{i}", success=True))
137+
138+
threads = [threading.Thread(target=write, args=(i,)) for i in range(50)]
139+
for t in threads:
140+
t.start()
141+
for t in threads:
142+
t.join()
143+
144+
tools = LogReader(tmp_path).get_agent_tools(run_id, "a1")
145+
assert len(tools) == 50
146+
assert {t.tool_name for t in tools} == {f"tool-{i}" for i in range(50)}

tests/mcp/test_server.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tests for the Hive MCP server protocol + tool dispatch (F1 coverage).
2+
3+
Drives HiveMCPServer message handling directly -- no stdio transport.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from hive.mcp.server import HiveMCPServer
13+
14+
15+
@pytest.fixture
16+
def server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> HiveMCPServer:
17+
# Run in an isolated cwd with its own profiles/ so the spawn path resolves
18+
# `default_profiles_dir()` deterministically (it checks Path.cwd()/profiles
19+
# first) instead of depending on the ambient working directory.
20+
monkeypatch.chdir(tmp_path)
21+
profiles = tmp_path / "profiles"
22+
profiles.mkdir()
23+
(profiles / "coder.yaml").write_text(
24+
'name: coder\nrole: "Test agent"\nmodel: claude-haiku-4-5\nautonomy: high\nmax_steps: 5\n'
25+
)
26+
hive_dir = tmp_path / ".hive"
27+
hive_dir.mkdir(parents=True) # so the store's hive.db parent exists
28+
return HiveMCPServer(hive_dir=hive_dir)
29+
30+
31+
class TestProtocol:
32+
async def test_initialize_returns_capabilities(self, server: HiveMCPServer) -> None:
33+
resp = await server._handle_message({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
34+
assert resp is not None
35+
assert resp["id"] == 1
36+
assert resp["result"]["protocolVersion"]
37+
assert "tools" in resp["result"]["capabilities"]
38+
assert resp["result"]["serverInfo"]["name"] == "hive"
39+
40+
async def test_tools_list_exposes_all_tools(self, server: HiveMCPServer) -> None:
41+
resp = await server._handle_message({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
42+
assert resp is not None
43+
names = {t["name"] for t in resp["result"]["tools"]}
44+
assert {"hive_init", "hive_start", "hive_status", "hive_spawn", "hive_models"} <= names
45+
# Every tool advertises an input schema.
46+
assert all("inputSchema" in t for t in resp["result"]["tools"])
47+
48+
async def test_unknown_method_returns_none(self, server: HiveMCPServer) -> None:
49+
assert await server._handle_message({"id": 9, "method": "resources/list"}) is None
50+
51+
52+
class TestToolsCall:
53+
async def test_status_on_empty_hive(self, server: HiveMCPServer) -> None:
54+
resp = await server._handle_message(
55+
{
56+
"jsonrpc": "2.0",
57+
"id": 3,
58+
"method": "tools/call",
59+
"params": {"name": "hive_status", "arguments": {}},
60+
}
61+
)
62+
assert resp is not None
63+
text = resp["result"]["content"][0]["text"]
64+
assert "No agents" in text
65+
66+
async def test_spawn_then_status(self, server: HiveMCPServer) -> None:
67+
spawn = await server._handle_message(
68+
{
69+
"jsonrpc": "2.0",
70+
"id": 4,
71+
"method": "tools/call",
72+
"params": {"name": "hive_spawn", "arguments": {"profile": "coder"}},
73+
}
74+
)
75+
assert "Spawned" in spawn["result"]["content"][0]["text"]
76+
77+
status = await server._handle_message(
78+
{
79+
"jsonrpc": "2.0",
80+
"id": 5,
81+
"method": "tools/call",
82+
"params": {"name": "hive_status", "arguments": {}},
83+
}
84+
)
85+
assert "coder" in status["result"]["content"][0]["text"]
86+
87+
async def test_missing_required_arg_is_caught(self, server: HiveMCPServer) -> None:
88+
"""A KeyError from a missing required arg becomes an error result, not a crash."""
89+
resp = await server._handle_message(
90+
{
91+
"jsonrpc": "2.0",
92+
"id": 6,
93+
"method": "tools/call",
94+
"params": {"name": "hive_spawn", "arguments": {}}, # missing "profile"
95+
}
96+
)
97+
assert resp["result"]["content"][0]["text"].startswith("Error:")
98+
99+
100+
class TestHandleTool:
101+
async def test_unknown_tool(self, server: HiveMCPServer) -> None:
102+
assert await server.handle_tool("hive_teleport", {}) == "Unknown tool: hive_teleport"
103+
104+
async def test_spawn_unknown_profile(self, server: HiveMCPServer) -> None:
105+
assert (await server.handle_tool("hive_spawn", {"profile": "ghost"})).startswith(
106+
"Profile not found"
107+
)
108+
109+
async def test_kill_missing_agent(self, server: HiveMCPServer) -> None:
110+
assert (await server.handle_tool("hive_kill", {"agent": "nobody"})).startswith(
111+
"Agent not found"
112+
)

0 commit comments

Comments
 (0)