Files
hermes-agent/tests/tools/test_connections_tool.py

538 lines
19 KiB
Python

"""Behavior tests for manage_connections.
DI-callable idiom: a fake client injected through manage_connections'
seams; no module mocks, no network.
"""
import json
import time
from unittest.mock import patch
import pytest
import tools.connections_tool # registers the tool
from tools.connections_tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
class FakeClient:
def __init__(self):
self.calls = []
def list_connectors(self):
self.calls.append(("list",))
return [
{"connector": "gmail", "enabled": True, "connected": False},
{"connector": "linear", "enabled": True, "connected": True},
]
def connections(self, connectors, *, reinitiate=False):
self.calls.append(("connections", tuple(connectors), reinitiate))
return {
"results": [
{
"connector": c,
"status": "initiated",
"connect_url": f"https://connect.example/{c}",
"instruction": f"finish authorizing {c} in the browser",
"reinitiated": reinitiate,
}
for c in connectors
],
"summary": {"total": len(connectors), "initiated": len(connectors)},
}
def test_status_lists_and_filters_connectors():
client = FakeClient()
out = json.loads(
manage_connections(
{"action": "status", "connectors": ["GMAIL"]},
client_factory=lambda: client,
)
)
assert out["connectors"] == [
{"connector": "gmail", "enabled": True, "connected": False}
]
def test_connect_returns_link_and_instruction_once_per_session():
client = FakeClient()
seen = set()
first = json.loads(
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=seen,
)
)
entry = first["results"][0]
assert entry["connect_url"] == "https://connect.example/gmail"
assert "instruction" in entry
second = json.loads(
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=seen,
)
)
assert "instruction" not in second["results"][0] # shown once per session
assert ("connections", ("gmail",), False) in client.calls
# A DIFFERENT session sharing the process still gets the guidance.
other_session = json.loads(
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=seen,
session_id="other-session",
)
)
assert "instruction" in other_session["results"][0]
def test_reconnect_sets_reinitiate():
client = FakeClient()
manage_connections(
{"action": "reconnect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
)
assert ("connections", ("gmail",), True) in client.calls
def test_connect_without_connectors_is_a_usage_error():
out = json.loads(
manage_connections({"action": "connect"}, client_factory=FakeClient)
)
assert "requires 'connectors'" in out["error"]
def test_disconnect_is_refused_before_any_gateway_call():
# De-authentication is user-only: the tool rejects it up front and the
# gateway never hears about it.
client = FakeClient()
out = json.loads(
manage_connections(
{"action": "disconnect", "connectors": ["gmail"]},
client_factory=lambda: client,
)
)
assert "error" in out
assert client.calls == []
def test_gateway_failure_is_a_model_actionable_error():
def exploding():
raise RuntimeError("gateway on fire")
out = json.loads(
manage_connections({"action": "status"}, client_factory=exploding)
)
assert "connector gateway request failed" in out["error"]
def test_mcp_actions_are_not_this_tools_business():
# Local MCP setup belongs to setup_mcp, which owns the desktop consent
# callback. Folding those actions in here promised a flow this tool has no
# way to reach, so they are rejected as unknown actions.
out = json.loads(
manage_connections({"action": "install", "server": "linear"})
)
assert "action must be one of" in out["error"]
assert "install" not in MANAGE_CONNECTIONS_SCHEMA["parameters"]["properties"]["action"]["enum"]
# ---------------------------------------------------------------------------
# action "wait": the waiting happens inside the call, not in the model's head
# ---------------------------------------------------------------------------
class WaitClient(FakeClient):
"""Reports `connector` connected from the `flips_on`-th list call onward.
`flips_on=None` never connects, which is the ordinary shape of a user who
wandered off mid-authorization.
"""
def __init__(self, connector="gmail", flips_on=None):
super().__init__()
self.connector = connector
self.flips_on = flips_on
self.polls = 0
self.on_poll = None
def list_connectors(self):
self.polls += 1
if self.on_poll is not None:
self.on_poll(self.polls)
connected = self.flips_on is not None and self.polls >= self.flips_on
return [{"connector": self.connector, "enabled": True, "connected": connected}]
@pytest.fixture
def no_sleep(monkeypatch):
"""Collect the wait slices instead of spending them, so tests run in ms."""
slices = []
monkeypatch.setattr(time, "sleep", lambda seconds: slices.append(seconds))
return slices
def _aged(rendered):
"""Rewind every recorded stamp past the just-minted window.
A real wait follows the connect across a turn boundary (a model round
trip); these tests call the two back to back, so without the rewind every
wait would hit the same-batch bounce instead of the path under test.
"""
for slugs in rendered.values():
for slug, stamp in list(slugs.items()):
slugs[slug] = stamp - 60.0
return rendered
def _wait(client, connectors=("gmail",), *, rendered=None, session_id=None, rewind=True, **extra):
args = {"action": "wait", "connectors": list(connectors)}
args.update(extra)
if rendered is None:
rendered = {str(session_id or ""): {c: time.monotonic() for c in connectors}}
if rewind:
_aged(rendered)
return json.loads(
manage_connections(
args,
client_factory=lambda: client,
rendered_links=rendered,
session_id=session_id,
)
)
def test_wait_returns_connected_when_the_gateway_flips_live(no_sleep):
"""The whole point: the link is shown, then the call absorbs the waiting.
Goes through 'connect' first so the link-rendering bookkeeping wait relies
on is exercised, not simulated.
"""
client = WaitClient(flips_on=3)
rendered = {}
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
rendered_links=rendered,
)
out = _wait(client, rendered=rendered)
assert out["status"] == "connected"
assert out["pending"] == []
assert out["connectors"] == [
{"connector": "gmail", "enabled": True, "connected": True}
]
assert client.polls == 3 # each poll is a live gateway read, none cached
# Waits are taken in one-second slices so the interrupt flag stays answered.
assert set(no_sleep) == {1.0}
def test_wait_timeout_lists_what_is_pending_and_denies_being_an_error(no_sleep):
client = WaitClient(flips_on=None)
out = _wait(client, timeout_seconds=20)
assert out["status"] == "timeout"
assert out["pending"] == ["gmail"]
assert out["connectors"] == []
assert "NOT an error" in out["note"]
assert "ASK THE USER" in out["note"]
# The three offers the model must put to the user.
assert "keep waiting" in out["note"]
assert "continue without" in out["note"]
assert "fresh connect links" in out["note"]
assert "timeout_note" not in out # nothing was clamped
assert client.polls == 5 # 20s of budget at a 5s cadence, the last gap partial
def test_wait_clamps_an_over_long_timeout_and_says_the_cap_was_applied(no_sleep):
client = WaitClient(flips_on=None)
out = _wait(client, timeout_seconds=600)
assert out["status"] == "timeout"
assert "180" in out["timeout_note"]
assert "capped" in out["timeout_note"]
assert client.polls == 37 # the cap, not the ask, bounded the loop
def test_wait_tolerates_transient_gateway_blips_but_not_a_dead_gateway(no_sleep):
# One blip costs a poll, never the whole wait: the connection still
# resolves when the gateway comes back. Three consecutive failures mean
# the gateway is genuinely down — the wait ends as a NEVER-error timeout
# that reports what the last good poll saw.
flaky = WaitClient(flips_on=4)
def blip_twice(n):
if n in (2, 3):
raise RuntimeError("gateway hiccup")
flaky.on_poll = blip_twice
out = _wait(flaky, timeout_seconds=180)
assert out["status"] == "connected"
assert flaky.polls == 4
dead = WaitClient(flips_on=None)
dead.on_poll = lambda n: (_ for _ in ()).throw(RuntimeError("gateway down"))
out = _wait(dead, timeout_seconds=180)
assert out["status"] == "timeout"
assert "stopped answering" in out["note"]
assert out["pending"] == ["gmail"]
assert "NOT an error" in out["note"]
assert dead.polls == 3 # gave up on the third consecutive failure
def test_wait_interrupted_mid_wait_reports_interrupted_not_an_error(no_sleep):
from tools.interrupt import set_interrupt
client = WaitClient(flips_on=None)
client.on_poll = lambda n: set_interrupt(True)
try:
out = _wait(client, timeout_seconds=180)
finally:
set_interrupt(False)
assert out["status"] == "interrupted"
assert out["pending"] == ["gmail"]
assert "NOT an error" in out["note"]
# Stopped in the first slice of the first wait rather than polling on.
assert client.polls == 1
assert no_sleep == []
def test_wait_refuses_a_connector_whose_link_this_session_never_showed(no_sleep):
"""Structural anti-footgun: waiting for a link nobody rendered is a stall.
Nothing is going to change, so the loop would burn its whole budget and
then report a pending connector the user was never asked to authorize.
"""
client = WaitClient(flips_on=1)
out = _wait(client, ("gmail", "linear"), rendered={"": {"gmail": 1.0}})
assert "wait refused" in out["error"]
assert "linear" in out["error"]
assert "connect" in out["error"]
assert client.polls == 0 # refused before any gateway read
def test_wait_in_the_same_batch_as_connect_bounces_instead_of_blocking(no_sleep):
"""connect→wait in one assistant turn: the user has not seen the links.
The bounce is a normal result, not an error — the model is told to send
its message first and wait next turn. Zero polls, zero sleep.
"""
client = WaitClient(flips_on=1)
rendered = {}
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
rendered_links=rendered,
)
out = _wait(client, rendered=rendered, rewind=False)
assert out["status"] == "pending"
assert out["pending"] == ["gmail"]
assert "has not seen them" in out["note"]
assert "next turn" in out["note"]
assert client.polls == 0
assert no_sleep == []
def test_wait_accepts_a_connector_that_was_already_connected(no_sleep):
"""connect on an already-live app mints no link; wait must still run.
The refusal guard exists for connectors this session never addressed —
an active one WAS addressed, and there is no link the user must see, so
an immediate wait legitimately returns connected on the first poll.
"""
class ActiveClient(WaitClient):
def connections(self, connectors, *, reinitiate=False):
self.calls.append(("connections", tuple(connectors), reinitiate))
return {
"results": [{"connector": c, "status": "active"} for c in connectors],
"summary": {"total": len(connectors), "active": len(connectors)},
}
client = ActiveClient(flips_on=1)
rendered = {}
out = json.loads(
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
rendered_links=rendered,
)
)
assert out["results"][0]["status"] == "active"
assert "connect_url" not in out["results"][0]
assert "Already connected" in out["results"][0]["note"]
# No rewind: even seconds after the connect, the wait runs (never_fresh).
waited = _wait(client, rendered=rendered, rewind=False)
assert waited["status"] == "connected"
assert client.polls == 1
def test_wait_link_bookkeeping_is_per_session(no_sleep):
"""A link shown in session A does not license a wait in session B."""
client = WaitClient(flips_on=1)
rendered = {}
manage_connections(
{"action": "connect", "connectors": ["gmail"]},
client_factory=lambda: client,
seen_instructions=set(),
rendered_links=rendered,
session_id="session-a",
)
assert _wait(client, rendered=rendered, session_id="session-a")["status"] == (
"connected"
)
other = _wait(client, rendered=rendered, session_id="session-b")
assert "wait refused" in other["error"]
def test_wait_requires_connectors():
out = json.loads(
manage_connections({"action": "wait"}, client_factory=FakeClient)
)
assert "requires 'connectors'" in out["error"]
def test_wait_never_rides_a_parallel_batch():
"""A three-minute block must not hold a gathered batch's siblings hostage."""
from agent.tool_dispatch_helpers import _NEVER_PARALLEL_TOOLS
assert "manage_connections" in _NEVER_PARALLEL_TOOLS
# ---------------------------------------------------------------------------
# reachability: a registered tool nobody enables is a tool nobody can call
# ---------------------------------------------------------------------------
def _session_tool_names(enabled_toolsets, *, connectors, disabled_toolsets=None):
"""Tool names a session would actually receive, through the real assembly.
Skips the tool_search step so the assertion is about NAME resolution and
check_fn, not about how many MCP servers the developer running the suite
happens to have configured.
"""
from model_tools import _compute_tool_definitions
from tools.registry import invalidate_check_fn_cache
with patch("tools.tool_gateway.config.connectors_available",
return_value=connectors):
invalidate_check_fn_cache()
try:
defs = _compute_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=True,
skip_tool_search_assembly=True,
)
finally:
invalidate_check_fn_cache()
return {d["function"]["name"] for d in defs}
def test_cli_session_gets_the_tool_outside_a_code_workspace(tmp_path, monkeypatch):
"""The path a plain `hermes` run takes: _get_platform_tools, no git cwd."""
from hermes_cli.tools_config import _get_platform_tools
monkeypatch.chdir(tmp_path)
enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True))
assert "connections" in enabled
assert "manage_connections" in _session_tool_names(enabled, connectors=True)
def test_cli_session_gets_the_tool_inside_a_code_workspace(monkeypatch):
"""Same resolver, run from this repo — the surface the live miss was on."""
from pathlib import Path
from hermes_cli.tools_config import _get_platform_tools
monkeypatch.chdir(Path(__file__).resolve().parents[2])
enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True))
assert "manage_connections" in _session_tool_names(enabled, connectors=True)
def test_tui_and_desktop_sessions_get_the_tool(monkeypatch):
"""The path the TUI/desktop gateway takes to build its selection."""
from tui_gateway.server import _load_enabled_toolsets
monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False)
for platform in ("tui", "desktop"):
selection = _load_enabled_toolsets(platform)
names = _session_tool_names(selection, connectors=True)
assert "manage_connections" in names, platform
def test_focus_mode_coding_posture_gets_the_tool(monkeypatch):
"""An engineer pinned to the coding posture still sees their accounts."""
from pathlib import Path
from agent.coding_context import coding_selection
repo = Path(__file__).resolve().parents[2]
monkeypatch.chdir(repo)
selection = coding_selection(
platform="cli", cwd=str(repo), config={"agent": {"coding_context": "focus"}}
)
assert selection == ["coding"] # posture collapse still collapses
assert "manage_connections" in _session_tool_names(selection, connectors=True)
def test_signed_out_session_sees_nothing(tmp_path, monkeypatch):
"""check_fn is the only entitlement gate, on every surface."""
from hermes_cli.tools_config import _get_platform_tools
from tui_gateway.server import _load_enabled_toolsets
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False)
selections = [
sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)),
_load_enabled_toolsets("tui"),
["coding"],
]
for selection in selections:
assert "manage_connections" not in _session_tool_names(
selection, connectors=False
), selection
def test_operator_can_still_turn_it_off(tmp_path, monkeypatch):
"""`agent.disabled_toolsets: [connections]` wins; a bundle name does not.
The name is added before the disabled subtraction, so the toolset behaves
like any other. Naming a platform composite instead must NOT strip it —
that branch preserves core tools on purpose (#33924).
"""
from hermes_cli.tools_config import _get_platform_tools
monkeypatch.chdir(tmp_path)
enabled = sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True))
assert "manage_connections" not in _session_tool_names(
enabled, connectors=True, disabled_toolsets=["connections"]
)
assert "manage_connections" in _session_tool_names(
enabled, connectors=True, disabled_toolsets=["hermes-cli"]
)
def test_tool_is_never_deferrable():
from tools.tool_search import is_deferrable_tool_name
# Core names short-circuit before the toolset check, so listing
# "connections" in _DIRECT_SURFACE_TOOLSETS would be redundant.
assert is_deferrable_tool_name("manage_connections") is False