538 lines
19 KiB
Python
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
|