molecule-sdk-python/tests/test_cli_connect.py
Hongming Wang 70d66cd814 feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c)
External agents that can't expose a public HTTP endpoint (laptops behind
NAT, ephemeral CI runners, hermes self-hosted, codex et al) had to reverse-
engineer the activity-poll loop from molecule-mcp-claude-channel/server.ts
because the SDK only shipped the push-mode `A2AServer` (Phase 30.8b).

This adds the complementary path:

- `RemoteAgentClient.fetch_inbound(since_id=…)` — one-shot GET against
  `/workspaces/:id/activity?type=a2a_receive&since_id=…`. Cursor-loss (410)
  surfaces as `CursorLostError`; caller resets and re-polls.
- `RemoteAgentClient.reply(msg, text)` — smart-routes to `/notify` for
  canvas users, `/a2a` (JSON-RPC envelope + X-Source-Workspace-Id) for peer
  agents. Hides the reply-path bifurcation from connector authors.
- `PollDelivery` / `PushDelivery` / `InboundDelivery` protocol — same
  `MessageHandler` callback works for both transports.
- `RemoteAgentClient.run_agent_loop(handler, delivery=None)` — combined
  heartbeat + state-poll + inbound dispatch. Defaults to `PollDelivery`.
  Async handlers detected and `asyncio.run`'d (matches A2AServer pattern).
  Sleep cadence = min(heartbeat_interval, delivery.interval).
- `python -m molecule_agent connect` CLI — one-line bootstrap. Loads a
  user's `module:function` via importlib, registers, runs the loop until
  pause/delete or SIGTERM. All flags also read from environment variables.

Tests: 50 new (test_inbound.py, test_cli_connect.py) covering every prod
branch — source normalization, cursor advancement, 410 reset, async/sync
handler dispatch, handler exception → log+continue+advance, smart-reply
routing for canvas vs peer vs unknown sources, run_agent_loop terminal
states, sleep-interval selection, CLI handler resolution failures.

Resolves #17.
2026-04-30 13:03:44 -07:00

168 lines
5.0 KiB
Python

"""Tests for `python -m molecule_agent connect` CLI handler resolution.
Run-loop integration is covered by tests/test_inbound.py — these tests only
exercise the CLI's argument parsing, handler resolution, and the
register-on-missing-token behavior. We do not start the full loop because
that's already covered, and starting it from a CLI test runs into signal
+ event-loop interactions that aren't worth reproducing here.
"""
from __future__ import annotations
import sys
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from molecule_agent.__main__ import _resolve_handler
# ---------------------------------------------------------------------------
# _resolve_handler
# ---------------------------------------------------------------------------
def _write_handler_module(tmp_path: Path, name: str, body: str) -> None:
"""Drop a handler module into tmp_path and prepend tmp_path to sys.path."""
p = tmp_path / f"{name}.py"
p.write_text(textwrap.dedent(body))
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
def test_resolve_handler_happy_path(tmp_path: Path):
_write_handler_module(
tmp_path,
"ok_handler_mod",
"""
def echo(msg, client):
return msg.text
""",
)
fn = _resolve_handler("ok_handler_mod:echo")
assert callable(fn)
# Sanity-check the resolved callable's name.
assert fn.__name__ == "echo"
def test_resolve_handler_missing_colon_exits(tmp_path: Path):
with pytest.raises(SystemExit, match="must be of the form"):
_resolve_handler("not_a_spec_no_colon")
def test_resolve_handler_empty_module_exits():
with pytest.raises(SystemExit, match="malformed"):
_resolve_handler(":fn")
def test_resolve_handler_empty_function_exits():
with pytest.raises(SystemExit, match="malformed"):
_resolve_handler("mod:")
def test_resolve_handler_import_error_exits():
with pytest.raises(SystemExit, match="could not import"):
_resolve_handler("definitely_not_a_real_module_xyzzy:fn")
def test_resolve_handler_attribute_error_exits(tmp_path: Path):
_write_handler_module(
tmp_path,
"no_func_mod",
"""
OTHER = 1
""",
)
with pytest.raises(SystemExit, match="no attribute"):
_resolve_handler("no_func_mod:not_there")
def test_resolve_handler_not_callable_exits(tmp_path: Path):
_write_handler_module(
tmp_path,
"not_callable_mod",
"""
IT_IS_AN_INT = 42
""",
)
with pytest.raises(SystemExit, match="not callable"):
_resolve_handler("not_callable_mod:IT_IS_AN_INT")
# ---------------------------------------------------------------------------
# _connect_command — registration / token-loading branches
# ---------------------------------------------------------------------------
def test_connect_command_register_failure_returns_2(tmp_path: Path, monkeypatch):
_write_handler_module(
tmp_path,
"rcfail_mod",
"""
def fn(msg, client):
return None
""",
)
from molecule_agent import __main__ as cli_mod
args = MagicMock()
args.handler = "rcfail_mod:fn"
args.platform_url = "http://platform.test"
args.workspace_id = "ws-zzz"
args.token = None
args.agent_name = None
args.reported_url = ""
args.poll_interval = 1.0
args.cursor_file = None
args.verbose = False
fake_client = MagicMock()
fake_client.load_token.return_value = None # no cached token
fake_client.register.side_effect = RuntimeError("network sad")
with patch("molecule_agent.client.RemoteAgentClient", return_value=fake_client):
rc = cli_mod._connect_command(args)
assert rc == 2
def test_connect_command_uses_provided_token_skips_register(tmp_path: Path, monkeypatch):
_write_handler_module(
tmp_path,
"tokset_mod",
"""
def fn(msg, client):
return None
""",
)
from molecule_agent import __main__ as cli_mod
args = MagicMock()
args.handler = "tokset_mod:fn"
args.platform_url = "http://platform.test"
args.workspace_id = "ws-zzz"
args.token = "explicit-token"
args.agent_name = None
args.reported_url = ""
args.poll_interval = 1.0
args.cursor_file = None
args.verbose = False
fake_client = MagicMock()
# Once save_token has been called, load_token should return the token,
# so register is NOT called.
fake_client.load_token.return_value = "explicit-token"
# run_agent_loop returns a terminal status — paused — so the function
# exits 0 cleanly without us having to signal-break the loop.
fake_client.run_agent_loop.return_value = "paused"
with patch("molecule_agent.client.RemoteAgentClient", return_value=fake_client):
rc = cli_mod._connect_command(args)
assert rc == 0
fake_client.save_token.assert_called_once_with("explicit-token")
fake_client.register.assert_not_called()
fake_client.run_agent_loop.assert_called_once()