honcho/honcho-cli/tests/test_commands.py

481 lines
20 KiB
Python

"""Command-level tests: init flow, destructive confirms, JSON output contract, exit codes.
Uses Typer's CliRunner against the real `app`. stdout is not a TTY under
CliRunner, so `use_json()` returns True and the CLI emits JSON —
which is exactly what scripts and agents consume.
"""
from __future__ import annotations
import json
import os
from contextlib import ExitStack, contextmanager
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from honcho_cli.commands.session import _next_page_command
from honcho_cli.main import app
@pytest.fixture
def cfg(tmp_path, monkeypatch):
"""Isolated config file + clean HONCHO_* env."""
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(k)
return f
@pytest.fixture
def runner():
return CliRunner()
# --------------------------------------------------------------------------- #
# Helpers for `session view` — a fake SDK message, page, and Session
def _view_msg(i: int) -> MagicMock:
return MagicMock(
id=f"m{i}",
peer_id="alice" if i % 2 == 0 else "bob",
content=f"msg-{i}",
token_count=i,
metadata={},
created_at=f"2026-01-01T00:00:00.{i:03d}Z",
)
def _fake_page(
items: list,
*,
total: int | None = None,
page: int | None = None,
pages: int | None = None,
has_next: bool = False,
next_page: MagicMock | None = None,
) -> MagicMock:
"""A stand-in for the SDK's ``SyncPage`` with explicit (non-mock) metadata."""
fake = MagicMock()
fake.items = items
fake.total = total
fake.page = page
fake.pages = pages
fake.has_next_page.return_value = has_next
fake.get_next_page.return_value = next_page
return fake
def _fake_session(page: MagicMock) -> MagicMock:
session = MagicMock()
session.messages.return_value = page
return session
def _patch_view(session: MagicMock, *, peer_id: str = ""):
"""Patch `session view`'s client + read-only Session construction."""
client = MagicMock()
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id=peer_id)
return _nested(
patch("honcho_cli.commands.session.get_client", return_value=(client, config)),
patch("honcho_cli.commands.session.Session", return_value=session),
)
@contextmanager
def _nested(*managers):
with ExitStack() as stack:
yield [stack.enter_context(m) for m in managers]
# --------------------------------------------------------------------------- #
# 1. `honcho init` end-to-end
class TestInit:
def test_first_run_writes_exact_shape(self, cfg, runner):
"""First run with --api-key + --base-url writes apiKey + environmentUrl only."""
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "test-key-123", "--base-url", "http://localhost:8000"],
)
assert result.exit_code == 0, result.stderr
assert json.loads(cfg.read_text()) == {
"environmentUrl": "http://localhost:8000",
"apiKey": "test-key-123",
}
def test_preserves_foreign_keys(self, cfg, runner):
"""Second run must not clobber sibling-tool keys (`hosts`, `sessions`, ...)."""
cfg.write_text(json.dumps({
"apiKey": "old",
"environmentUrl": "http://old.example",
"hosts": {"claude_code": {"peerName": "user"}},
"sessions": {"/Users/user": "home-chat"},
"sessionStrategy": "chat-instance",
}))
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "new-key", "--base-url", "https://api.honcho.dev"],
)
assert result.exit_code == 0, result.stderr
on_disk = json.loads(cfg.read_text())
assert on_disk["apiKey"] == "new-key"
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
assert on_disk["hosts"] == {"claude_code": {"peerName": "user"}}
assert on_disk["sessions"] == {"/Users/user": "home-chat"}
assert on_disk["sessionStrategy"] == "chat-instance"
# --------------------------------------------------------------------------- #
# 2. Destructive-confirm guards
class TestDestructiveConfirm:
def test_workspace_delete_aborts_on_no(self, cfg, runner):
"""`workspace delete` without --yes: 'n' at prompt → no API call, non-zero exit."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
fake = MagicMock()
fake.sessions.return_value = MagicMock(has_next_page=lambda: False, _raw_items=[])
with patch("honcho_cli.commands.workspace.get_client", return_value=(fake, MagicMock())), \
patch("honcho_cli.commands.workspace._with_workspace", return_value=fake):
result = runner.invoke(app, ["workspace", "delete", "ws1"], input="n\n")
assert result.exit_code != 0
fake.delete_workspace.assert_not_called()
def test_session_delete_aborts_on_no(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = MagicMock()
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="s1", workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["session", "delete", "s1"], input="n\n")
assert result.exit_code != 0
session.delete.assert_not_called()
# --------------------------------------------------------------------------- #
# 3. JSON output contract — scripts pipe these
class TestJsonContract:
def test_workspace_list_json_array_shape(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.workspaces.return_value = ["ws-a", "ws-b"]
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, MagicMock())):
result = runner.invoke(app, ["workspace", "list"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == [{"id": "ws-a"}, {"id": "ws-b"}]
def test_workspace_search_preserves_full_content_in_json_mode(self, cfg, runner):
cfg.write_text(json.dumps({
"apiKey": "k",
"environmentUrl": "http://localhost:8000",
"workspace_id": "ws1",
}))
message = MagicMock(
id="msg1",
content="x" * 250,
peer_id="peer1",
session_id="sess1",
created_at="2026-01-01T00:00:00Z",
)
client = MagicMock()
client.search.return_value = [message]
config = MagicMock(workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["workspace", "search", "topic", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert payload == [{
"id": "msg1",
"content": "x" * 250,
"peer_id": "peer1",
"session_id": "sess1",
"created_at": "2026-01-01T00:00:00Z",
}]
def test_message_get_returns_single_json_object(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
msg = MagicMock(
id="msg1",
peer_id="peer1",
content="hello",
token_count=7,
metadata={"kind": "demo"},
created_at="2026-01-01T00:00:00Z",
)
session = MagicMock()
session.get_message.return_value = msg
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="sess1", workspace_id="ws1")
with patch("honcho_cli.commands.message.get_client", return_value=(client, config)):
result = runner.invoke(app, ["message", "get", "msg1", "-s", "sess1", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == {
"id": "msg1",
"peer_id": "peer1",
"content": "hello",
"token_count": 7,
"metadata": {"kind": "demo"},
"created_at": "2026-01-01T00:00:00Z",
}
@pytest.mark.parametrize("last", ["0", "-5"])
def test_message_list_rejects_non_positive_last(self, cfg, runner, last):
"""Non-positive --last silently returned an empty list via slice semantics."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.message.get_client", return_value=(MagicMock(), config)) as get_client:
result = runner.invoke(
app,
["message", "list", "sess1", "--last", last, "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
get_client.assert_not_called()
def test_session_view_json_is_chronological_window(self, cfg, runner):
"""`session view` returns the most recent N messages oldest→newest by default."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Server returns newest-first when reverse=True (m4, m3, m2, m1, m0).
page = _fake_page([_view_msg(i) for i in range(4, -1, -1)], total=5)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--last", "3", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
# Most recent 3 (m4,m3,m2) flipped to chronological: m2, m3, m4.
assert [m["id"] for m in payload] == ["m2", "m3", "m4"]
assert [m["content"] for m in payload] == ["msg-2", "msg-3", "msg-4"]
session.messages.assert_called_once()
assert session.messages.call_args.kwargs["reverse"] is True
def test_session_view_rejects_all_with_last(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)):
result = runner.invoke(
app,
["session", "view", "sess1", "--all", "--last", "10", "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
def test_session_view_page_fetches_exact_server_page(self, cfg, runner):
"""`--page N --size M` hits the API page directly (oldest-first)."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Page 2 contents, already chronological.
page = _fake_page(
[_view_msg(i) for i in (50, 51, 52)],
total=120,
page=2,
pages=3,
has_next=True,
)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "2", "--size", "50", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert [m["id"] for m in payload] == ["m50", "m51", "m52"]
session.messages.assert_called_once_with(
filters=None,
page=2,
size=50,
reverse=False,
)
def test_session_view_page_with_reverse_pages_from_newest(self, cfg, runner):
"""`--reverse --page N` pages from the newest end, not the oldest one flipped."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
page = _fake_page([_view_msg(i) for i in (9, 8, 7)], total=30, page=1, pages=10)
session = _fake_session(page)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "1", "--reverse", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
assert session.messages.call_args.kwargs["reverse"] is True
# Server order is preserved: no local flip on top of a reversed fetch.
assert [m["id"] for m in json.loads(result.stdout)] == ["m9", "m8", "m7"]
def test_session_view_rejects_page_with_last(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)):
result = runner.invoke(
app,
["session", "view", "sess1", "--page", "2", "--last", "10", "-w", "ws1"],
)
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
@pytest.mark.parametrize(
"args",
[
["--size", "10"], # --size requires --page
["--page", "1", "--size", "500"], # over the server's 100 ceiling
["--page", "0"],
["--last", "0"],
],
)
def test_session_view_rejects_bad_flags_before_any_api_call(self, cfg, runner, args):
"""Flag validation runs before the client is built, so nothing reaches the API."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
with patch("honcho_cli.commands.session.get_client") as get_client:
result = runner.invoke(app, ["session", "view", "sess1", *args, "-w", "ws1"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS"
get_client.assert_not_called()
def test_session_view_does_not_create_the_session(self, cfg, runner):
"""`view` is read-only: it must not use the get-or-create client.session()."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = _fake_session(_fake_page([_view_msg(1)], total=1))
client = MagicMock()
config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="")
with patch("honcho_cli.commands.session.get_client", return_value=(client, config)), \
patch("honcho_cli.commands.session.Session", return_value=session) as session_cls:
result = runner.invoke(app, ["session", "view", "sess1", "-w", "ws1", "--json"])
assert result.exit_code == 0, result.stderr
client.session.assert_not_called()
session_cls.assert_called_once_with("sess1", client)
@pytest.mark.parametrize(
("kwargs", "expected"),
[
(
{},
"honcho session view s1 --page 2 --size 50",
),
(
{"reverse": True, "show_ids": True},
"honcho session view s1 --page 2 --size 50 --reverse --ids",
),
(
{"workspace": "ws2", "peer": "alice"},
"honcho session view s1 --page 2 --size 50 -w ws2 -p alice",
),
],
)
def test_next_page_command_carries_the_invocation_scope(self, kwargs, expected):
"""A copied hint must land on the same workspace, peer, and ordering."""
opts = {"reverse": False, "show_ids": False, "workspace": None, "peer": None, **kwargs}
assert _next_page_command("s1", 2, 50, **opts) == expected
@pytest.mark.parametrize(
("session_id", "workspace", "expected_fragment"),
[
("has space", None, "'has space'"),
("a;rm -rf x", None, "'a;rm -rf x'"),
("s1", "ws$(id)", "'ws$(id)'"),
("s1", "ws|tee", "'ws|tee'"),
],
)
def test_next_page_command_shell_quotes_identifiers(
self, session_id, workspace, expected_fragment
):
"""IDs only reject ?#%/\\ and control chars, so spaces and metacharacters reach here."""
hint = _next_page_command(
session_id,
2,
50,
reverse=False,
show_ids=False,
workspace=workspace,
peer=None,
)
assert expected_fragment in hint
def test_session_view_hint_carries_group_level_scope(self, cfg, runner):
"""-w/-p also parse at group level, where the command-level params are None."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = _fake_session(_fake_page([_view_msg(1)], total=10, page=1, pages=5))
with _patch_view(session), patch("honcho_cli.output.use_json", return_value=False):
result = runner.invoke(
app,
["session", "-w", "ws2", "-p", "alice", "view", "sess1", "--page", "1"],
)
assert result.exit_code == 0, result.stderr
assert "-w ws2" in result.stderr
assert "-p alice" in result.stderr
def test_session_view_last_walks_pages_past_the_page_cap(self, cfg, runner):
"""`--last N` above the 100-item server cap keeps walking instead of truncating."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
# Newest-first pages of 100: m149..m50, then m49..m0.
second = _fake_page([_view_msg(i) for i in range(49, -1, -1)], total=150)
first = _fake_page(
[_view_msg(i) for i in range(149, 49, -1)],
total=150,
has_next=True,
next_page=second,
)
session = _fake_session(first)
with _patch_view(session):
result = runner.invoke(
app,
["session", "view", "sess1", "--last", "120", "-w", "ws1", "--json"],
)
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert len(payload) == 120
# Oldest of the 120-message tail first, newest last.
assert payload[0]["id"] == "m30"
assert payload[-1]["id"] == "m149"
assert session.messages.call_args.kwargs["size"] == 100
# --------------------------------------------------------------------------- #
# 4. Exit codes on error
class TestExitCodes:
def test_no_workspace_scoped_exits_nonzero_with_code(self, cfg, runner):
"""Running a workspace-scoped command with no workspace → NO_WORKSPACE on stderr, exit 1."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
result = runner.invoke(app, ["peer", "list"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "NO_WORKSPACE"
def test_not_found_exits_nonzero_with_code(self, cfg, runner):
"""SDK NotFoundError → structured error, exit 1."""
from honcho import NotFoundError
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.peer.return_value.get_card.side_effect = NotFoundError("not found")
config = MagicMock(peer_id="missing", session_id="", workspace_id="ws1")
with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)):
result = runner.invoke(app, ["peer", "inspect", "missing", "-w", "ws1"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "PEER_NOT_FOUND"