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.
168 lines
5.0 KiB
Python
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()
|