Merge branch 'main' into feat/dashboard-skill-analytics
This commit is contained in:
@@ -42,9 +42,10 @@ class TestToolProgressCallback:
|
||||
def test_emits_tool_call_start(self, mock_conn, event_loop_fixture):
|
||||
"""Tool progress should emit a ToolCallStart update."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
# Run callback in the event loop context
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
@@ -66,9 +67,10 @@ class TestToolProgressCallback:
|
||||
def test_handles_string_args(self, mock_conn, event_loop_fixture):
|
||||
"""If args is a JSON string, it should be parsed."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
@@ -82,9 +84,10 @@ class TestToolProgressCallback:
|
||||
def test_handles_non_dict_args(self, mock_conn, event_loop_fixture):
|
||||
"""If args is not a dict, it should be wrapped."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
@@ -98,10 +101,11 @@ class TestToolProgressCallback:
|
||||
def test_duplicate_same_name_tool_calls_use_fifo_ids(self, mock_conn, event_loop_fixture):
|
||||
"""Multiple same-name tool calls should be tracked independently in order."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
step_cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
step_cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
@@ -163,7 +167,7 @@ class TestStepCallback:
|
||||
tool_call_ids = {"terminal": "tc-abc123"}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
@@ -181,7 +185,7 @@ class TestStepCallback:
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
cb(1, [{"name": "unknown_tool", "result": "ok"}])
|
||||
@@ -193,7 +197,7 @@ class TestStepCallback:
|
||||
tool_call_ids = {"read_file": "tc-def456"}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
@@ -212,7 +216,7 @@ class TestStepCallback:
|
||||
tool_call_ids = {"terminal": deque(["tc-xyz789"])}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
|
||||
patch("acp_adapter.events.build_tool_complete") as mock_btc:
|
||||
@@ -224,7 +228,7 @@ class TestStepCallback:
|
||||
cb(1, [{"name": "terminal", "result": '{"output": "hello"}'}])
|
||||
|
||||
mock_btc.assert_called_once_with(
|
||||
"tc-xyz789", "terminal", result='{"output": "hello"}'
|
||||
"tc-xyz789", "terminal", result='{"output": "hello"}', function_args=None, snapshot=None
|
||||
)
|
||||
|
||||
def test_none_result_passed_through(self, mock_conn, event_loop_fixture):
|
||||
@@ -234,7 +238,7 @@ class TestStepCallback:
|
||||
tool_call_ids = {"web_search": deque(["tc-aaa"])}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
|
||||
patch("acp_adapter.events.build_tool_complete") as mock_btc:
|
||||
@@ -244,7 +248,50 @@ class TestStepCallback:
|
||||
|
||||
cb(1, [{"name": "web_search", "result": None}])
|
||||
|
||||
mock_btc.assert_called_once_with("tc-aaa", "web_search", result=None)
|
||||
mock_btc.assert_called_once_with("tc-aaa", "web_search", result=None, function_args=None, snapshot=None)
|
||||
|
||||
def test_step_callback_passes_arguments_and_snapshot(self, mock_conn, event_loop_fixture):
|
||||
from collections import deque
|
||||
|
||||
tool_call_ids = {"write_file": deque(["tc-write"])}
|
||||
tool_call_meta = {"tc-write": {"args": {"path": "fallback.txt"}, "snapshot": "snap"}}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
|
||||
patch("acp_adapter.events.build_tool_complete") as mock_btc:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb(1, [{"name": "write_file", "result": '{"bytes_written": 23}', "arguments": {"path": "diff-test.txt"}}])
|
||||
|
||||
mock_btc.assert_called_once_with(
|
||||
"tc-write",
|
||||
"write_file",
|
||||
result='{"bytes_written": 23}',
|
||||
function_args={"path": "diff-test.txt"},
|
||||
snapshot="snap",
|
||||
)
|
||||
|
||||
def test_tool_progress_captures_snapshot_metadata(self, mock_conn, event_loop_fixture):
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
with patch("acp_adapter.events.make_tool_call_id", return_value="tc-meta"), \
|
||||
patch("acp_adapter.events._send_update") as mock_send, \
|
||||
patch("agent.display.capture_local_edit_snapshot", return_value="snapshot"):
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
cb("tool.started", "write_file", None, {"path": "diff-test.txt", "content": "hello"})
|
||||
|
||||
assert list(tool_call_ids["write_file"]) == ["tc-meta"]
|
||||
assert tool_call_meta["tc-meta"] == {
|
||||
"args": {"path": "diff-test.txt", "content": "hello"},
|
||||
"snapshot": "snapshot",
|
||||
}
|
||||
mock_send.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,7 @@ from acp.schema import (
|
||||
|
||||
from acp_adapter.server import HermesACPAgent
|
||||
from acp_adapter.session import SessionManager
|
||||
from acp_adapter.tools import build_tool_start
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -181,6 +182,25 @@ class TestMcpRegistrationE2E:
|
||||
assert complete_event.raw_output is not None
|
||||
assert "hello" in str(complete_event.raw_output)
|
||||
|
||||
def test_patch_mode_tool_start_emits_diff_blocks_for_v4a_patch(self):
|
||||
update = build_tool_start(
|
||||
"tc-1",
|
||||
"patch",
|
||||
{
|
||||
"mode": "patch",
|
||||
"patch": "*** Begin Patch\n*** Update File: src/app.py\n@@\n-old line\n+new line\n*** Add File: src/new.py\n+hello\n*** End Patch",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(update.content) == 2
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "src/app.py"
|
||||
assert update.content[0].old_text == "old line"
|
||||
assert update.content[0].new_text == "new line"
|
||||
assert update.content[1].type == "diff"
|
||||
assert update.content[1].path == "src/new.py"
|
||||
assert update.content[1].new_text == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_tool_results_paired_by_call_id(self, acp_agent, mock_manager):
|
||||
"""The ToolCallUpdate's toolCallId must match the ToolCallStart's."""
|
||||
|
||||
210
tests/acp/test_ping_suppression.py
Normal file
210
tests/acp/test_ping_suppression.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Tests for acp_adapter.entry._BenignProbeMethodFilter.
|
||||
|
||||
Covers both the isolated filter logic and the full end-to-end path where a
|
||||
client sends a bare JSON-RPC ``ping`` request over stdio and the acp runtime
|
||||
surfaces the resulting ``RequestError`` via ``logging.exception("Background
|
||||
task failed", ...)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from acp.exceptions import RequestError
|
||||
|
||||
from acp_adapter.entry import _BenignProbeMethodFilter
|
||||
|
||||
|
||||
# -- Unit tests on the filter itself ----------------------------------------
|
||||
|
||||
|
||||
def _make_record(msg: str, exc: BaseException | None) -> logging.LogRecord:
|
||||
record = logging.LogRecord(
|
||||
name="root",
|
||||
level=logging.ERROR,
|
||||
pathname=__file__,
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=(),
|
||||
exc_info=(type(exc), exc, exc.__traceback__) if exc else None,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _bake_tb(exc: BaseException) -> BaseException:
|
||||
try:
|
||||
raise exc
|
||||
except BaseException as e: # noqa: BLE001
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["ping", "health", "healthcheck"])
|
||||
def test_filter_suppresses_benign_probe(method: str) -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found(method))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is False
|
||||
|
||||
|
||||
def test_filter_allows_real_method_not_found() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("session/custom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_non_request_error() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RuntimeError("boom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_different_message_even_for_ping() -> None:
|
||||
"""Only 'Background task failed' is muted — other messages pass through."""
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("ping"))
|
||||
record = _make_record("Some other context", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_request_error_with_different_code() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.invalid_params({"method": "ping"}))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_log_without_exc_info() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
record = _make_record("Background task failed", None)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
# -- End-to-end: drive a real JSON-RPC `ping` through acp.run_agent ---------
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Minimal acp.Agent stub — we only need the router to build."""
|
||||
|
||||
async def initialize(self, **kwargs): # noqa: ANN003
|
||||
from acp.schema import AgentCapabilities, InitializeResponse
|
||||
|
||||
return InitializeResponse(protocol_version=1, agent_capabilities=AgentCapabilities())
|
||||
|
||||
async def new_session(self, cwd, mcp_servers=None, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import NewSessionResponse
|
||||
|
||||
return NewSessionResponse(session_id="test")
|
||||
|
||||
async def prompt(self, session_id, prompt, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import PromptResponse
|
||||
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
async def cancel(self, session_id, **kwargs): # noqa: ANN001, ANN003
|
||||
pass
|
||||
|
||||
async def authenticate(self, **kwargs): # noqa: ANN003
|
||||
pass
|
||||
|
||||
def on_connect(self, conn): # noqa: ANN001
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_ping_request_produces_proper_response_and_no_stderr_noise(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A bare ``ping`` must get a JSON-RPC -32601 back AND leave stderr clean
|
||||
when the filter is installed on the handler.
|
||||
"""
|
||||
import acp
|
||||
|
||||
# Attach the filter to a fresh stream handler that mirrors entry._setup_logging.
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(logging.Formatter("%(name)s|%(levelname)s|%(message)s"))
|
||||
handler.addFilter(_BenignProbeMethodFilter())
|
||||
root = logging.getLogger()
|
||||
prior_handlers = root.handlers[:]
|
||||
prior_level = root.level
|
||||
root.handlers = [handler]
|
||||
root.setLevel(logging.INFO)
|
||||
# Also suppress propagation of caplog's default handler interfering with
|
||||
# our stream (caplog still captures via its own propagation hook).
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Pipe client -> agent
|
||||
client_to_agent_r, client_to_agent_w = os.pipe()
|
||||
# Pipe agent -> client
|
||||
agent_to_client_r, agent_to_client_w = os.pipe()
|
||||
|
||||
in_read_file = os.fdopen(client_to_agent_r, "rb", buffering=0)
|
||||
in_write_file = os.fdopen(client_to_agent_w, "wb", buffering=0)
|
||||
out_read_file = os.fdopen(agent_to_client_r, "rb", buffering=0)
|
||||
out_write_file = os.fdopen(agent_to_client_w, "wb", buffering=0)
|
||||
|
||||
# Agent reads its input from this StreamReader:
|
||||
agent_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
agent_input_proto = asyncio.StreamReaderProtocol(agent_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: agent_input_proto, in_read_file)
|
||||
|
||||
# Agent writes its output via this StreamWriter:
|
||||
out_transport, out_protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, out_write_file
|
||||
)
|
||||
agent_output = asyncio.StreamWriter(out_transport, out_protocol, None, loop)
|
||||
|
||||
# Test harness reads agent output via this StreamReader:
|
||||
client_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
client_input_proto = asyncio.StreamReaderProtocol(client_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: client_input_proto, out_read_file)
|
||||
|
||||
agent_task = asyncio.create_task(
|
||||
acp.run_agent(
|
||||
_FakeAgent(),
|
||||
input_stream=agent_output,
|
||||
output_stream=agent_input,
|
||||
use_unstable_protocol=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Send a bare `ping`
|
||||
request = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {}}
|
||||
in_write_file.write((json.dumps(request) + "\n").encode())
|
||||
in_write_file.flush()
|
||||
|
||||
response_line = await asyncio.wait_for(client_input.readline(), timeout=5.0)
|
||||
# Give the supervisor task a tick to fire (filter should eat it)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
response = json.loads(response_line.decode())
|
||||
assert response["error"]["code"] == -32601, response
|
||||
assert response["error"]["data"] == {"method": "ping"}, response
|
||||
|
||||
logs = stream.getvalue()
|
||||
assert "Background task failed" not in logs, (
|
||||
f"ping noise leaked to stderr:\n{logs}"
|
||||
)
|
||||
|
||||
# Clean shutdown
|
||||
in_write_file.close()
|
||||
try:
|
||||
await asyncio.wait_for(agent_task, timeout=2.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except BaseException: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
root.handlers = prior_handlers
|
||||
root.setLevel(prior_level)
|
||||
@@ -20,7 +20,9 @@ from acp.schema import (
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SessionModelState,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeResponse,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
@@ -127,6 +129,25 @@ class TestSessionOps:
|
||||
assert state is not None
|
||||
assert state.cwd == "/home/user/project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_returns_model_state(self):
|
||||
manager = SessionManager(
|
||||
agent_factory=lambda: SimpleNamespace(model="gpt-5.4", provider="openai-codex")
|
||||
)
|
||||
acp_agent = HermesACPAgent(session_manager=manager)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.models.curated_models_for_provider",
|
||||
return_value=[("gpt-5.4", "recommended"), ("gpt-5.4-mini", "")],
|
||||
):
|
||||
resp = await acp_agent.new_session(cwd="/tmp")
|
||||
|
||||
assert isinstance(resp.models, SessionModelState)
|
||||
assert resp.models.current_model_id == "openai-codex:gpt-5.4"
|
||||
assert resp.models.available_models[0].model_id == "openai-codex:gpt-5.4"
|
||||
assert resp.models.available_models[0].description is not None
|
||||
assert "Provider:" in resp.models.available_models[0].description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_commands_include_help(self, agent):
|
||||
help_cmd = next(
|
||||
@@ -167,13 +188,6 @@ class TestSessionOps:
|
||||
assert model_cmd.input is not None
|
||||
assert model_cmd.input.root.hint == "model name to switch to"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_schedules_available_commands_update(self, agent):
|
||||
with patch.object(agent, "_schedule_available_commands_update") as mock_schedule:
|
||||
resp = await agent.new_session(cwd="/home/user/project")
|
||||
|
||||
mock_schedule.assert_called_once_with(resp.session_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sets_event(self, agent):
|
||||
resp = await agent.new_session(cwd=".")
|
||||
@@ -187,41 +201,11 @@ class TestSessionOps:
|
||||
# Should not raise
|
||||
await agent.cancel(session_id="does-not-exist")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_returns_response(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
load_resp = await agent.load_session(cwd="/tmp", session_id=resp.session_id)
|
||||
assert isinstance(load_resp, LoadSessionResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_schedules_available_commands_update(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
with patch.object(agent, "_schedule_available_commands_update") as mock_schedule:
|
||||
load_resp = await agent.load_session(cwd="/tmp", session_id=resp.session_id)
|
||||
|
||||
assert isinstance(load_resp, LoadSessionResponse)
|
||||
mock_schedule.assert_called_once_with(resp.session_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_not_found_returns_none(self, agent):
|
||||
resp = await agent.load_session(cwd="/tmp", session_id="bogus")
|
||||
assert resp is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_returns_response(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
resume_resp = await agent.resume_session(cwd="/tmp", session_id=resp.session_id)
|
||||
assert isinstance(resume_resp, ResumeSessionResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_schedules_available_commands_update(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
with patch.object(agent, "_schedule_available_commands_update") as mock_schedule:
|
||||
resume_resp = await agent.resume_session(cwd="/tmp", session_id=resp.session_id)
|
||||
|
||||
assert isinstance(resume_resp, ResumeSessionResponse)
|
||||
mock_schedule.assert_called_once_with(resp.session_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_creates_new_if_missing(self, agent):
|
||||
resume_resp = await agent.resume_session(cwd="/tmp", session_id="nonexistent")
|
||||
@@ -234,14 +218,6 @@ class TestSessionOps:
|
||||
|
||||
|
||||
class TestListAndFork:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions(self, agent):
|
||||
await agent.new_session(cwd="/a")
|
||||
await agent.new_session(cwd="/b")
|
||||
resp = await agent.list_sessions()
|
||||
assert isinstance(resp, ListSessionsResponse)
|
||||
assert len(resp.sessions) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/original")
|
||||
@@ -250,14 +226,31 @@ class TestListAndFork:
|
||||
assert fork_resp.session_id != new_resp.session_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session_schedules_available_commands_update(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/original")
|
||||
with patch.object(agent, "_schedule_available_commands_update") as mock_schedule:
|
||||
fork_resp = await agent.fork_session(cwd="/forked", session_id=new_resp.session_id)
|
||||
async def test_list_sessions_includes_title_and_updated_at(self, agent):
|
||||
with patch.object(
|
||||
agent.session_manager,
|
||||
"list_sessions",
|
||||
return_value=[
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"cwd": "/tmp/project",
|
||||
"title": "Fix Zed session history",
|
||||
"updated_at": 123.0,
|
||||
}
|
||||
],
|
||||
):
|
||||
resp = await agent.list_sessions(cwd="/tmp/project")
|
||||
|
||||
assert fork_resp.session_id
|
||||
mock_schedule.assert_called_once_with(fork_resp.session_id)
|
||||
assert isinstance(resp.sessions[0], SessionInfo)
|
||||
assert resp.sessions[0].title == "Fix Zed session history"
|
||||
assert resp.sessions[0].updated_at == "123.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_passes_cwd_filter(self, agent):
|
||||
with patch.object(agent.session_manager, "list_sessions", return_value=[]) as mock_list:
|
||||
await agent.list_sessions(cwd="/mnt/e/Projects/AI/browser-link-3")
|
||||
|
||||
mock_list.assert_called_once_with(cwd="/mnt/e/Projects/AI/browser-link-3")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session configuration / model routing
|
||||
@@ -274,20 +267,6 @@ class TestSessionConfiguration:
|
||||
assert isinstance(resp, SetSessionModeResponse)
|
||||
assert getattr(state, "mode", None) == "chat"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_returns_response(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
resp = await agent.set_config_option(
|
||||
config_id="approval_mode",
|
||||
session_id=new_resp.session_id,
|
||||
value="auto",
|
||||
)
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, SetSessionConfigOptionResponse)
|
||||
assert getattr(state, "config_options", {}) == {"approval_mode": "auto"}
|
||||
assert resp.config_options == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_accepts_stable_session_config_methods(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
@@ -326,6 +305,53 @@ class TestSessionConfiguration:
|
||||
assert result == {}
|
||||
assert state.model == "gpt-5.4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_session_model_accepts_provider_prefixed_choice(self, tmp_path, monkeypatch):
|
||||
runtime_calls = []
|
||||
|
||||
def fake_resolve_runtime_provider(requested=None, **kwargs):
|
||||
runtime_calls.append(requested)
|
||||
provider = requested or "openrouter"
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": "anthropic_messages" if provider == "anthropic" else "chat_completions",
|
||||
"base_url": f"https://{provider}.example/v1",
|
||||
"api_key": f"{provider}-key",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
|
||||
def fake_agent(**kwargs):
|
||||
return SimpleNamespace(
|
||||
model=kwargs.get("model"),
|
||||
provider=kwargs.get("provider"),
|
||||
base_url=kwargs.get("base_url"),
|
||||
api_mode=kwargs.get("api_mode"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
|
||||
"model": {"provider": "openrouter", "default": "openrouter/gpt-5"}
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
fake_resolve_runtime_provider,
|
||||
)
|
||||
manager = SessionManager(db=SessionDB(tmp_path / "state.db"))
|
||||
|
||||
with patch("run_agent.AIAgent", side_effect=fake_agent):
|
||||
acp_agent = HermesACPAgent(session_manager=manager)
|
||||
state = manager.create_session(cwd="/tmp")
|
||||
result = await acp_agent.set_session_model(
|
||||
model_id="anthropic:claude-sonnet-4-6",
|
||||
session_id=state.session_id,
|
||||
)
|
||||
|
||||
assert isinstance(result, SetSessionModelResponse)
|
||||
assert state.model == "claude-sonnet-4-6"
|
||||
assert state.agent.provider == "anthropic"
|
||||
assert state.agent.base_url == "https://anthropic.example/v1"
|
||||
assert runtime_calls[-1] == "anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prompt
|
||||
@@ -423,6 +449,31 @@ class TestPrompt:
|
||||
update = last_call[1].get("update") or last_call[0][1]
|
||||
assert update.session_update == "agent_message_chunk"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_auto_titles_session(self, agent):
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "Here is the fix.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "fix the broken ACP history"},
|
||||
{"role": "assistant", "content": "Here is the fix."},
|
||||
],
|
||||
})
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as mock_title:
|
||||
prompt = [TextContentBlock(type="text", text="fix the broken ACP history")]
|
||||
await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
mock_title.assert_called_once()
|
||||
assert mock_title.call_args.args[1] == new_resp.session_id
|
||||
assert mock_title.call_args.args[2] == "fix the broken ACP history"
|
||||
assert mock_title.call_args.args[3] == "Here is the fix."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_populates_usage_from_top_level_run_conversation_fields(self, agent):
|
||||
"""ACP should map top-level token fields into PromptResponse.usage."""
|
||||
@@ -808,47 +859,3 @@ class TestRegisterSessionMcpServers:
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=RuntimeError("boom")):
|
||||
# Should not raise
|
||||
await agent._register_session_mcp_servers(state, [server])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_calls_register(self, agent, mock_manager):
|
||||
"""new_session passes mcp_servers to _register_session_mcp_servers."""
|
||||
with patch.object(agent, "_register_session_mcp_servers", new_callable=AsyncMock) as mock_reg:
|
||||
resp = await agent.new_session(cwd="/tmp", mcp_servers=["fake"])
|
||||
assert resp is not None
|
||||
mock_reg.assert_called_once()
|
||||
# Second arg should be the mcp_servers list
|
||||
assert mock_reg.call_args[0][1] == ["fake"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_calls_register(self, agent, mock_manager):
|
||||
"""load_session passes mcp_servers to _register_session_mcp_servers."""
|
||||
# Create a session first so load can find it
|
||||
state = mock_manager.create_session(cwd="/tmp")
|
||||
sid = state.session_id
|
||||
|
||||
with patch.object(agent, "_register_session_mcp_servers", new_callable=AsyncMock) as mock_reg:
|
||||
resp = await agent.load_session(cwd="/tmp", session_id=sid, mcp_servers=["fake"])
|
||||
assert resp is not None
|
||||
mock_reg.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_calls_register(self, agent, mock_manager):
|
||||
"""resume_session passes mcp_servers to _register_session_mcp_servers."""
|
||||
state = mock_manager.create_session(cwd="/tmp")
|
||||
sid = state.session_id
|
||||
|
||||
with patch.object(agent, "_register_session_mcp_servers", new_callable=AsyncMock) as mock_reg:
|
||||
resp = await agent.resume_session(cwd="/tmp", session_id=sid, mcp_servers=["fake"])
|
||||
assert resp is not None
|
||||
mock_reg.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session_calls_register(self, agent, mock_manager):
|
||||
"""fork_session passes mcp_servers to _register_session_mcp_servers."""
|
||||
state = mock_manager.create_session(cwd="/tmp")
|
||||
sid = state.session_id
|
||||
|
||||
with patch.object(agent, "_register_session_mcp_servers", new_callable=AsyncMock) as mock_reg:
|
||||
resp = await agent.fork_session(cwd="/tmp", session_id=sid, mcp_servers=["fake"])
|
||||
assert resp is not None
|
||||
mock_reg.assert_called_once()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -100,15 +101,23 @@ class TestListAndCleanup:
|
||||
def test_list_sessions_returns_created(self, manager):
|
||||
s1 = manager.create_session(cwd="/a")
|
||||
s2 = manager.create_session(cwd="/b")
|
||||
s1.history.append({"role": "user", "content": "hello from a"})
|
||||
s2.history.append({"role": "user", "content": "hello from b"})
|
||||
listing = manager.list_sessions()
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert s1.session_id in ids
|
||||
assert s2.session_id in ids
|
||||
assert len(listing) == 2
|
||||
|
||||
def test_list_sessions_hides_empty_threads(self, manager):
|
||||
manager.create_session(cwd="/empty")
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_cleanup_clears_all(self, manager):
|
||||
manager.create_session()
|
||||
manager.create_session()
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
s1.history.append({"role": "user", "content": "one"})
|
||||
s2.history.append({"role": "user", "content": "two"})
|
||||
assert len(manager.list_sessions()) == 2
|
||||
manager.cleanup()
|
||||
assert manager.list_sessions() == []
|
||||
@@ -194,6 +203,8 @@ class TestPersistence:
|
||||
def test_list_sessions_includes_db_only(self, manager):
|
||||
"""Sessions only in DB (not in memory) appear in list_sessions."""
|
||||
state = manager.create_session(cwd="/db-only")
|
||||
state.history.append({"role": "user", "content": "database only thread"})
|
||||
manager.save_session(state.session_id)
|
||||
sid = state.session_id
|
||||
|
||||
# Drop from memory.
|
||||
@@ -204,6 +215,53 @@ class TestPersistence:
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert sid in ids
|
||||
|
||||
def test_list_sessions_filters_by_cwd(self, manager):
|
||||
keep = manager.create_session(cwd="/keep")
|
||||
drop = manager.create_session(cwd="/drop")
|
||||
keep.history.append({"role": "user", "content": "keep me"})
|
||||
drop.history.append({"role": "user", "content": "drop me"})
|
||||
|
||||
listing = manager.list_sessions(cwd="/keep")
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert keep.session_id in ids
|
||||
assert drop.session_id not in ids
|
||||
|
||||
def test_list_sessions_matches_windows_and_wsl_paths(self, manager):
|
||||
state = manager.create_session(cwd="/mnt/e/Projects/AI/browser-link-3")
|
||||
state.history.append({"role": "user", "content": "same project from WSL"})
|
||||
|
||||
listing = manager.list_sessions(cwd=r"E:\Projects\AI\browser-link-3")
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert state.session_id in ids
|
||||
|
||||
def test_list_sessions_prefers_title_then_preview(self, manager):
|
||||
state = manager.create_session(cwd="/named")
|
||||
state.history.append({"role": "user", "content": "Investigate broken ACP history in Zed"})
|
||||
manager.save_session(state.session_id)
|
||||
db = manager._get_db()
|
||||
db.set_session_title(state.session_id, "Fix Zed ACP history")
|
||||
|
||||
listing = manager.list_sessions(cwd="/named")
|
||||
assert listing[0]["title"] == "Fix Zed ACP history"
|
||||
|
||||
db.set_session_title(state.session_id, "")
|
||||
listing = manager.list_sessions(cwd="/named")
|
||||
assert listing[0]["title"].startswith("Investigate broken ACP history")
|
||||
|
||||
def test_list_sessions_sorted_by_most_recent_activity(self, manager):
|
||||
older = manager.create_session(cwd="/ordered")
|
||||
older.history.append({"role": "user", "content": "older"})
|
||||
manager.save_session(older.session_id)
|
||||
time.sleep(0.02)
|
||||
newer = manager.create_session(cwd="/ordered")
|
||||
newer.history.append({"role": "user", "content": "newer"})
|
||||
manager.save_session(newer.session_id)
|
||||
|
||||
listing = manager.list_sessions(cwd="/ordered")
|
||||
assert [item["session_id"] for item in listing[:2]] == [newer.session_id, older.session_id]
|
||||
assert listing[0]["updated_at"]
|
||||
assert listing[1]["updated_at"]
|
||||
|
||||
def test_fork_restores_source_from_db(self, manager):
|
||||
"""Forking a session that is only in DB should work."""
|
||||
original = manager.create_session()
|
||||
|
||||
@@ -215,6 +215,46 @@ class TestBuildToolComplete:
|
||||
assert len(display_text) < 6000
|
||||
assert "truncated" in display_text
|
||||
|
||||
def test_build_tool_complete_for_patch_uses_diff_blocks(self):
|
||||
"""Completed patch calls should keep structured diff content for Zed."""
|
||||
patch_result = (
|
||||
'{"success": true, "diff": "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1,2 @@\\n old line\\n+new line\\n", '
|
||||
'"files_modified": ["README.md"]}'
|
||||
)
|
||||
result = build_tool_complete("tc-p1", "patch", patch_result)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert len(result.content) == 1
|
||||
diff_item = result.content[0]
|
||||
assert isinstance(diff_item, FileEditToolCallContent)
|
||||
assert diff_item.path == "README.md"
|
||||
assert diff_item.old_text == "old line"
|
||||
assert diff_item.new_text == "old line\nnew line"
|
||||
|
||||
def test_build_tool_complete_for_patch_falls_back_to_text_when_no_diff(self):
|
||||
result = build_tool_complete("tc-p2", "patch", '{"success": true}')
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert isinstance(result.content[0], ContentToolCallContent)
|
||||
|
||||
def test_build_tool_complete_for_write_file_uses_snapshot_diff(self, tmp_path):
|
||||
target = tmp_path / "diff-test.txt"
|
||||
snapshot = type("Snapshot", (), {"paths": [target], "before": {str(target): None}})()
|
||||
target.write_text("hello from hermes\n", encoding="utf-8")
|
||||
|
||||
result = build_tool_complete(
|
||||
"tc-wf1",
|
||||
"write_file",
|
||||
'{"bytes_written": 18, "dirs_created": false}',
|
||||
function_args={"path": str(target), "content": "hello from hermes\n"},
|
||||
snapshot=snapshot,
|
||||
)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert len(result.content) == 1
|
||||
diff_item = result.content[0]
|
||||
assert isinstance(diff_item, FileEditToolCallContent)
|
||||
assert diff_item.path.endswith("diff-test.txt")
|
||||
assert diff_item.old_text is None
|
||||
assert diff_item.new_text == "hello from hermes"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_locations
|
||||
|
||||
@@ -951,13 +951,21 @@ class TestBuildAnthropicKwargs:
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
)
|
||||
assert kwargs["thinking"] == {"type": "adaptive"}
|
||||
# Adaptive thinking + display="summarized" keeps reasoning text
|
||||
# populated in the response stream (Opus 4.7 default is "omitted").
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "high"}
|
||||
assert "budget_tokens" not in kwargs["thinking"]
|
||||
assert "temperature" not in kwargs
|
||||
assert kwargs["max_tokens"] == 4096
|
||||
|
||||
def test_reasoning_config_maps_xhigh_to_max_effort_for_4_6_models(self):
|
||||
def test_reasoning_config_downgrades_xhigh_to_max_for_4_6_models(self):
|
||||
# Opus 4.7 added "xhigh" as a distinct effort level (low/medium/high/
|
||||
# xhigh/max). Opus 4.6 only supports low/medium/high/max — sending
|
||||
# "xhigh" there returns an API 400. Preserve the pre-migration
|
||||
# behavior of aliasing xhigh→max on pre-4.7 adaptive models so users
|
||||
# who prefer xhigh as their default don't 400 every request when
|
||||
# switching back to 4.6.
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "think harder"}],
|
||||
@@ -965,9 +973,53 @@ class TestBuildAnthropicKwargs:
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
)
|
||||
assert kwargs["thinking"] == {"type": "adaptive"}
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "max"}
|
||||
|
||||
def test_reasoning_config_preserves_xhigh_for_4_7_models(self):
|
||||
# On 4.7+ xhigh is a real level and the recommended default for
|
||||
# coding/agentic work — keep it distinct from max.
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "think harder"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
)
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "xhigh"}
|
||||
|
||||
def test_reasoning_config_maps_max_effort_for_4_7_models(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "maximum reasoning please"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "max"},
|
||||
)
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "max"}
|
||||
|
||||
def test_opus_4_7_strips_sampling_params(self):
|
||||
# Opus 4.7 returns 400 on non-default temperature/top_p/top_k.
|
||||
# build_anthropic_kwargs must strip them as a safety net even if an
|
||||
# upstream caller injects them for older-model compatibility.
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
)
|
||||
# Manually inject sampling params then re-run through the guard.
|
||||
# Because build_anthropic_kwargs doesn't currently accept sampling
|
||||
# params through its signature, we exercise the strip behavior by
|
||||
# calling the internal predicate directly.
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
assert _forbids_sampling_params("claude-opus-4-7") is True
|
||||
assert _forbids_sampling_params("claude-opus-4-6") is False
|
||||
assert _forbids_sampling_params("claude-sonnet-4-5") is False
|
||||
|
||||
def test_reasoning_disabled(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
@@ -1248,6 +1300,21 @@ class TestNormalizeResponse:
|
||||
assert r2 == "tool_calls"
|
||||
assert r3 == "length"
|
||||
|
||||
def test_stop_reason_refusal_and_context_exceeded(self):
|
||||
# Claude 4.5+ introduced two new stop_reason values the Messages API
|
||||
# returns. We map both to OpenAI-style finish_reasons upstream
|
||||
# handlers already understand, instead of silently collapsing to
|
||||
# "stop" (old behavior).
|
||||
block = SimpleNamespace(type="text", text="")
|
||||
_, refusal_reason = normalize_anthropic_response(
|
||||
self._make_response([block], "refusal")
|
||||
)
|
||||
_, overflow_reason = normalize_anthropic_response(
|
||||
self._make_response([block], "model_context_window_exceeded")
|
||||
)
|
||||
assert refusal_reason == "content_filter"
|
||||
assert overflow_reason == "length"
|
||||
|
||||
def test_no_text_content(self):
|
||||
block = SimpleNamespace(
|
||||
type="tool_use", id="tc_1", name="search", input={"q": "hi"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
107
tests/agent/test_auxiliary_client_anthropic_custom.py
Normal file
107
tests/agent/test_auxiliary_client_anthropic_custom.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests for agent.auxiliary_client._try_custom_endpoint's anthropic_messages branch.
|
||||
|
||||
When a user configures a custom endpoint with ``api_mode: anthropic_messages``
|
||||
(e.g. MiniMax, Zhipu GLM, LiteLLM in Anthropic-proxy mode), auxiliary tasks
|
||||
(compression, web_extract, session_search, title generation) must use the
|
||||
native Anthropic transport rather than being silently downgraded to an
|
||||
OpenAI-wire client that speaks the wrong protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
for key in (
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _install_anthropic_adapter_mocks():
|
||||
"""Patch build_anthropic_client so the test doesn't need the SDK."""
|
||||
fake_client = MagicMock(name="anthropic_client")
|
||||
return patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client",
|
||||
return_value=fake_client,
|
||||
), fake_client
|
||||
|
||||
|
||||
def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
|
||||
"""api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=(
|
||||
"https://api.minimax.io/anthropic",
|
||||
"minimax-key",
|
||||
"anthropic_messages",
|
||||
),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="claude-sonnet-4-6",
|
||||
):
|
||||
adapter_patch, fake_client = _install_anthropic_adapter_mocks()
|
||||
with adapter_patch:
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
assert isinstance(client, AnthropicAuxiliaryClient), (
|
||||
"Custom endpoint with api_mode=anthropic_messages must return the "
|
||||
f"native Anthropic wrapper, got {type(client).__name__}"
|
||||
)
|
||||
assert model == "claude-sonnet-4-6"
|
||||
# Wrapper should NOT be marked as OAuth — third-party endpoints are
|
||||
# always API-key authenticated.
|
||||
assert client.api_key == "minimax-key"
|
||||
assert client.base_url == "https://api.minimax.io/anthropic"
|
||||
|
||||
|
||||
def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
|
||||
"""Graceful degradation when anthropic SDK is unavailable."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint
|
||||
|
||||
import_error = ImportError("anthropic package not installed")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://api.minimax.io/anthropic", "k", "anthropic_messages"),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="claude-sonnet-4-6",
|
||||
), patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client",
|
||||
side_effect=import_error,
|
||||
):
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
# Should fall back to an OpenAI-wire client rather than returning
|
||||
# (None, None) — the tool still needs to do *something*.
|
||||
assert client is not None
|
||||
assert model == "claude-sonnet-4-6"
|
||||
# OpenAI client, not AnthropicAuxiliaryClient.
|
||||
from agent.auxiliary_client import AnthropicAuxiliaryClient
|
||||
assert not isinstance(client, AnthropicAuxiliaryClient)
|
||||
|
||||
|
||||
def test_custom_endpoint_chat_completions_still_uses_openai_wire():
|
||||
"""Regression: default path (no api_mode) must remain OpenAI client."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://api.example.com/v1", "key", None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="my-model",
|
||||
):
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
assert client is not None
|
||||
assert model == "my-model"
|
||||
assert not isinstance(client, AnthropicAuxiliaryClient)
|
||||
311
tests/agent/test_auxiliary_main_first.py
Normal file
311
tests/agent/test_auxiliary_main_first.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Regression tests for the ``auto`` → main-model-first policy.
|
||||
|
||||
Prior to this change, aggregator users (OpenRouter / Nous Portal) had aux
|
||||
tasks routed through a cheap provider-side default (Gemini Flash) while
|
||||
non-aggregator users got their main model. This made behavior inconsistent
|
||||
and surprising — users picked Claude but got Gemini Flash summaries.
|
||||
|
||||
The current policy: ``auto`` means "use my main chat model" for every user,
|
||||
regardless of provider type. Explicit per-task overrides in ``config.yaml``
|
||||
(``auxiliary.<task>.provider``) still win. The cheap fallback chain only
|
||||
runs when the main provider has no working client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Text aux tasks — _resolve_auto ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveAutoMainFirst:
|
||||
"""_resolve_auto() must prefer main provider + main model for every user."""
|
||||
|
||||
def test_openrouter_main_uses_main_model_for_aux(self, monkeypatch):
|
||||
"""OpenRouter main user → aux uses their picked OR model, not Gemini Flash."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="anthropic/claude-sonnet-4.6",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "anthropic/claude-sonnet-4.6")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto()
|
||||
|
||||
assert client is mock_client
|
||||
assert model == "anthropic/claude-sonnet-4.6"
|
||||
# Verify it asked resolve_provider_client for the MAIN provider+model,
|
||||
# not a fallback-chain provider
|
||||
mock_resolve.assert_called_once()
|
||||
assert mock_resolve.call_args.args[0] == "openrouter"
|
||||
assert mock_resolve.call_args.args[1] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_nous_main_uses_main_model_for_aux(self, monkeypatch):
|
||||
"""Nous Portal main user → aux uses their picked Nous model, not free-tier MiMo."""
|
||||
# No OPENROUTER_API_KEY → ensures if main failed we'd fall to chain
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="anthropic/claude-opus-4.6",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "anthropic/claude-opus-4.6")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto()
|
||||
|
||||
assert client is mock_client
|
||||
assert model == "anthropic/claude-opus-4.6"
|
||||
assert mock_resolve.call_args.args[0] == "nous"
|
||||
|
||||
def test_non_aggregator_main_still_uses_main(self, monkeypatch):
|
||||
"""Non-aggregator main (DeepSeek) → unchanged behavior, main model used."""
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "ds-test")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="deepseek",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="deepseek-chat",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "deepseek-chat")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto()
|
||||
|
||||
assert client is mock_client
|
||||
assert model == "deepseek-chat"
|
||||
assert mock_resolve.call_args.args[0] == "deepseek"
|
||||
|
||||
def test_main_unavailable_falls_through_to_chain(self, monkeypatch):
|
||||
"""Main provider with no working client → fall back to aux chain."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
chain_client = MagicMock()
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="anthropic",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="claude-opus",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(None, None), # main provider has no client
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_openrouter",
|
||||
return_value=(chain_client, "google/gemini-3-flash-preview"),
|
||||
):
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto()
|
||||
|
||||
assert client is chain_client
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
|
||||
def test_no_main_config_uses_chain_directly(self):
|
||||
"""No main provider configured → skip step 1, use chain (no regression)."""
|
||||
chain_client = MagicMock()
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="",
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_openrouter",
|
||||
return_value=(chain_client, "google/gemini-3-flash-preview"),
|
||||
):
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto()
|
||||
|
||||
assert client is chain_client
|
||||
|
||||
def test_runtime_override_wins_over_config(self, monkeypatch):
|
||||
"""main_runtime kwarg overrides config-read main provider/model."""
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="config-model",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "runtime-model")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
_resolve_auto(main_runtime={
|
||||
"provider": "anthropic",
|
||||
"model": "runtime-model",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"api_mode": "",
|
||||
})
|
||||
|
||||
# Runtime override wins
|
||||
assert mock_resolve.call_args.args[0] == "anthropic"
|
||||
assert mock_resolve.call_args.args[1] == "runtime-model"
|
||||
|
||||
|
||||
# ── Vision — resolve_vision_provider_client ─────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveVisionMainFirst:
|
||||
"""Vision auto-detection prefers main provider + main model first."""
|
||||
|
||||
def test_openrouter_main_vision_uses_main_model(self, monkeypatch):
|
||||
"""OpenRouter main with vision-capable model → aux vision uses main model."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="anthropic/claude-sonnet-4.6",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve, patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "anthropic/claude-sonnet-4.6")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "openrouter"
|
||||
assert client is mock_client
|
||||
assert model == "anthropic/claude-sonnet-4.6"
|
||||
# Verify it did NOT call the strict vision backend for OpenRouter
|
||||
# (which would have used a cheap gemini-flash-preview default)
|
||||
mock_resolve.assert_called_once()
|
||||
assert mock_resolve.call_args.args[0] == "openrouter"
|
||||
assert mock_resolve.call_args.args[1] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_nous_main_vision_uses_main_model(self):
|
||||
"""Nous Portal main → aux vision uses main model, not free-tier MiMo-V2-Omni."""
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="openai/gpt-5",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve, patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "openai/gpt-5")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "nous"
|
||||
assert model == "openai/gpt-5"
|
||||
|
||||
def test_exotic_provider_with_vision_override_preserved(self):
|
||||
"""xiaomi → mimo-v2-omni override still wins over main_model."""
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="xiaomi",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="mimo-v2-pro", # text model
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve, patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
):
|
||||
mock_resolve.return_value = (MagicMock(), "mimo-v2-omni")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "xiaomi"
|
||||
# Should use mimo-v2-omni (vision override), not mimo-v2-pro (text main)
|
||||
assert mock_resolve.call_args.args[1] == "mimo-v2-omni"
|
||||
|
||||
def test_main_unavailable_vision_falls_through_to_aggregators(self):
|
||||
"""Main provider fails → fall back to OpenRouter/Nous strict backends."""
|
||||
fallback_client = MagicMock()
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="deepseek",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="deepseek-chat",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_strict_vision_backend",
|
||||
return_value=(fallback_client, "google/gemini-3-flash-preview"),
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
):
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert client is fallback_client
|
||||
assert provider in ("openrouter", "nous")
|
||||
|
||||
def test_explicit_provider_override_still_wins(self):
|
||||
"""Explicit config override bypasses main-first policy."""
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="anthropic/claude-opus-4.6",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("nous", None, None, None, None), # explicit override
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_strict_vision_backend"
|
||||
) as mock_strict:
|
||||
mock_strict.return_value = (MagicMock(), "nous-default-model")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
# Explicit "nous" override → uses strict backend, NOT main model path
|
||||
assert provider == "nous"
|
||||
mock_strict.assert_called_once_with("nous")
|
||||
|
||||
|
||||
# ── Constant cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_aggregator_providers_constant_removed():
|
||||
"""The dead _AGGREGATOR_PROVIDERS constant should no longer live in the module.
|
||||
|
||||
Removed when the main-first policy made the aggregator-skip guard obsolete.
|
||||
"""
|
||||
import agent.auxiliary_client as aux_mod
|
||||
|
||||
assert not hasattr(aux_mod, "_AGGREGATOR_PROVIDERS"), (
|
||||
"_AGGREGATOR_PROVIDERS was removed when _resolve_auto stopped "
|
||||
"treating aggregators specially. If you re-added it, the main-first "
|
||||
"policy may have regressed."
|
||||
)
|
||||
@@ -232,7 +232,7 @@ class TestResolveVisionProviderClientModelNormalization:
|
||||
|
||||
assert provider == "zai"
|
||||
assert client is not None
|
||||
assert model == "glm-5.1"
|
||||
assert model == "glm-5v-turbo" # zai has dedicated vision model in _PROVIDER_VISION_MODELS
|
||||
|
||||
|
||||
class TestVisionPathApiMode:
|
||||
|
||||
1232
tests/agent/test_bedrock_adapter.py
Normal file
1232
tests/agent/test_bedrock_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
440
tests/agent/test_bedrock_integration.py
Normal file
440
tests/agent/test_bedrock_integration.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""Integration tests for the AWS Bedrock provider wiring.
|
||||
|
||||
Verifies that the Bedrock provider is correctly registered in the
|
||||
provider registry, model catalog, and runtime resolution pipeline.
|
||||
These tests do NOT require AWS credentials or boto3 — all AWS calls
|
||||
are mocked.
|
||||
|
||||
Note: Tests that import ``hermes_cli.auth`` or ``hermes_cli.runtime_provider``
|
||||
require Python 3.10+ due to ``str | None`` type syntax in the import chain.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
"""Verify Bedrock is registered in PROVIDER_REGISTRY."""
|
||||
|
||||
def test_bedrock_in_registry(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
assert "bedrock" in PROVIDER_REGISTRY
|
||||
|
||||
def test_bedrock_auth_type_is_aws_sdk(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.auth_type == "aws_sdk"
|
||||
|
||||
def test_bedrock_has_no_api_key_env_vars(self):
|
||||
"""Bedrock uses the AWS SDK credential chain, not API keys."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.api_key_env_vars == ()
|
||||
|
||||
def test_bedrock_base_url_env_var(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.base_url_env_var == "BEDROCK_BASE_URL"
|
||||
|
||||
|
||||
class TestProviderAliases:
|
||||
"""Verify Bedrock aliases resolve correctly."""
|
||||
|
||||
def test_aws_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("aws") == "bedrock"
|
||||
|
||||
def test_aws_bedrock_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("aws-bedrock") == "bedrock"
|
||||
|
||||
def test_amazon_bedrock_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("amazon-bedrock") == "bedrock"
|
||||
|
||||
def test_amazon_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("amazon") == "bedrock"
|
||||
|
||||
|
||||
class TestProviderLabels:
|
||||
"""Verify Bedrock appears in provider labels."""
|
||||
|
||||
def test_bedrock_label(self):
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS.get("bedrock") == "AWS Bedrock"
|
||||
|
||||
|
||||
class TestModelCatalog:
|
||||
"""Verify Bedrock has a static model fallback list."""
|
||||
|
||||
def test_bedrock_has_curated_models(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
assert len(models) > 0
|
||||
|
||||
def test_bedrock_models_include_claude(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
claude_models = [m for m in models if "anthropic.claude" in m]
|
||||
assert len(claude_models) > 0
|
||||
|
||||
def test_bedrock_models_include_nova(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
nova_models = [m for m in models if "amazon.nova" in m]
|
||||
assert len(nova_models) > 0
|
||||
|
||||
|
||||
class TestResolveProvider:
|
||||
"""Verify resolve_provider() handles bedrock correctly."""
|
||||
|
||||
def test_explicit_bedrock_resolves(self, monkeypatch):
|
||||
"""When user explicitly requests 'bedrock', it should resolve."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
# bedrock is in the registry, so resolve_provider should return it
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("bedrock")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_aws_alias_resolves_to_bedrock(self):
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("aws")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_amazon_bedrock_alias_resolves(self):
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("amazon-bedrock")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_auto_detect_with_aws_credentials(self, monkeypatch):
|
||||
"""When AWS credentials are present and no other provider is configured,
|
||||
auto-detect should find bedrock."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
# Clear all other provider env vars
|
||||
for var in ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Set AWS credentials
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
# Mock the auth store to have no active provider
|
||||
with patch("hermes_cli.auth._load_auth_store", return_value={}):
|
||||
result = resolve_provider("auto")
|
||||
assert result == "bedrock"
|
||||
|
||||
|
||||
class TestRuntimeProvider:
|
||||
"""Verify resolve_runtime_provider() handles bedrock correctly."""
|
||||
|
||||
def test_bedrock_runtime_resolution(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
monkeypatch.setenv("AWS_REGION", "eu-west-1")
|
||||
|
||||
# Mock resolve_provider to return bedrock
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
|
||||
assert result["provider"] == "bedrock"
|
||||
assert result["api_mode"] == "bedrock_converse"
|
||||
assert result["region"] == "eu-west-1"
|
||||
assert "bedrock-runtime.eu-west-1.amazonaws.com" in result["base_url"]
|
||||
assert result["api_key"] == "aws-sdk"
|
||||
|
||||
def test_bedrock_runtime_default_region(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "default")
|
||||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||||
monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
|
||||
assert result["region"] == "us-east-1"
|
||||
|
||||
def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch):
|
||||
"""When bedrock is auto-detected (not explicitly requested) and no
|
||||
credentials are found, runtime resolution should raise AuthError."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
# Clear all AWS env vars
|
||||
for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Mock both the provider resolution and boto3's credential chain
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_credentials.return_value = None
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}), \
|
||||
patch("hermes_cli.runtime_provider.resolve_requested_provider", return_value="auto"), \
|
||||
patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}):
|
||||
import botocore.session as _bs
|
||||
_bs.get_session = MagicMock(return_value=mock_session)
|
||||
with pytest.raises(AuthError, match="No AWS credentials"):
|
||||
resolve_runtime_provider(requested="auto")
|
||||
|
||||
def test_bedrock_runtime_explicit_skips_credential_check(self, monkeypatch):
|
||||
"""When user explicitly requests bedrock, trust boto3's credential chain
|
||||
even if env-var detection finds nothing (covers IMDS, SSO, etc.)."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
# No AWS env vars set — but explicit bedrock request should not raise
|
||||
for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
assert result["provider"] == "bedrock"
|
||||
assert result["api_mode"] == "bedrock_converse"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# providers.py integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProvidersModule:
|
||||
"""Verify bedrock is wired into hermes_cli/providers.py."""
|
||||
|
||||
def test_bedrock_alias_in_providers(self):
|
||||
from hermes_cli.providers import ALIASES
|
||||
assert ALIASES.get("bedrock") is None # "bedrock" IS the canonical name, not an alias
|
||||
assert ALIASES.get("aws") == "bedrock"
|
||||
assert ALIASES.get("aws-bedrock") == "bedrock"
|
||||
|
||||
def test_bedrock_transport_mapping(self):
|
||||
from hermes_cli.providers import TRANSPORT_TO_API_MODE
|
||||
assert TRANSPORT_TO_API_MODE.get("bedrock_converse") == "bedrock_converse"
|
||||
|
||||
def test_determine_api_mode_from_bedrock_url(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
assert determine_api_mode(
|
||||
"unknown", "https://bedrock-runtime.us-east-1.amazonaws.com"
|
||||
) == "bedrock_converse"
|
||||
|
||||
def test_label_override(self):
|
||||
from hermes_cli.providers import _LABEL_OVERRIDES
|
||||
assert _LABEL_OVERRIDES.get("bedrock") == "AWS Bedrock"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classifier integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorClassifierBedrock:
|
||||
"""Verify Bedrock error patterns are in the global error classifier."""
|
||||
|
||||
def test_throttling_in_rate_limit_patterns(self):
|
||||
from agent.error_classifier import _RATE_LIMIT_PATTERNS
|
||||
assert "throttlingexception" in _RATE_LIMIT_PATTERNS
|
||||
|
||||
def test_context_overflow_patterns(self):
|
||||
from agent.error_classifier import _CONTEXT_OVERFLOW_PATTERNS
|
||||
assert "input is too long" in _CONTEXT_OVERFLOW_PATTERNS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pyproject.toml bedrock extra
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPackaging:
|
||||
"""Verify bedrock optional dependency is declared."""
|
||||
|
||||
def test_bedrock_extra_exists(self):
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
# Read pyproject.toml to verify [bedrock] extra
|
||||
toml_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
content = toml_path.read_text()
|
||||
assert 'bedrock = ["boto3' in content
|
||||
|
||||
def test_bedrock_in_all_extra(self):
|
||||
from pathlib import Path
|
||||
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
|
||||
assert '"hermes-agent[bedrock]"' in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model ID dot preservation — regression for #11976
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS Bedrock inference-profile model IDs embed structural dots:
|
||||
#
|
||||
# global.anthropic.claude-opus-4-7
|
||||
# us.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# apac.anthropic.claude-haiku-4-5
|
||||
#
|
||||
# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens
|
||||
# unless the caller opts in via ``preserve_dots=True``. Before this fix,
|
||||
# ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock``
|
||||
# provider, so Claude-on-Bedrock requests went out with
|
||||
# ``global-anthropic-claude-opus-4-7`` (all dots mangled to hyphens) and
|
||||
# Bedrock rejected them with:
|
||||
#
|
||||
# HTTP 400: The provided model identifier is invalid.
|
||||
#
|
||||
# The fix adds ``bedrock`` to the preserve-dots provider allowlist and
|
||||
# ``bedrock-runtime.`` to the base-URL heuristic, mirroring the shape of
|
||||
# the opencode-go fix for #5211 (commit f77be22c), which extended this
|
||||
# same allowlist.
|
||||
|
||||
|
||||
class TestBedrockPreserveDotsFlag:
|
||||
"""``AIAgent._anthropic_preserve_dots`` must return True on Bedrock so
|
||||
inference-profile IDs survive the normalize step intact."""
|
||||
|
||||
def test_bedrock_provider_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="bedrock", base_url="")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_bedrock_runtime_us_east_1_url_preserves_dots(self):
|
||||
"""Defense-in-depth: even without an explicit ``provider="bedrock"``,
|
||||
a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not
|
||||
mangle dots."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self):
|
||||
"""Reporter-reported region (ap-northeast-2) exercises the same
|
||||
base-URL heuristic."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://bedrock-runtime.ap-northeast-2.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_non_bedrock_aws_url_does_not_preserve_dots(self):
|
||||
"""Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``)
|
||||
must not accidentally activate the dot-preservation heuristic —
|
||||
the heuristic is scoped to the ``bedrock-runtime.`` substring
|
||||
specifically."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://s3.us-east-1.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is False
|
||||
|
||||
def test_anthropic_native_still_does_not_preserve_dots(self):
|
||||
"""Canary: adding Bedrock to the allowlist must not weaken the
|
||||
existing Anthropic native behaviour — ``claude-sonnet-4.6`` still
|
||||
becomes ``claude-sonnet-4-6`` for the Anthropic API."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is False
|
||||
|
||||
|
||||
class TestBedrockModelNameNormalization:
|
||||
"""End-to-end: ``normalize_model_name`` + the preserve-dots flag
|
||||
reproduce the exact production request shape for each Bedrock model
|
||||
family, confirming the fix resolves the reporter's HTTP 400."""
|
||||
|
||||
def test_global_anthropic_inference_profile_preserved(self):
|
||||
"""The reporter's exact model ID."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"global.anthropic.claude-opus-4-7", preserve_dots=True
|
||||
) == "global.anthropic.claude-opus-4-7"
|
||||
|
||||
def test_us_anthropic_dated_inference_profile_preserved(self):
|
||||
"""Regional + dated Sonnet inference profile."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
preserve_dots=True,
|
||||
) == "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
|
||||
def test_apac_anthropic_haiku_inference_profile_preserved(self):
|
||||
"""APAC inference profile — same structural-dot shape."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"apac.anthropic.claude-haiku-4-5", preserve_dots=True
|
||||
) == "apac.anthropic.claude-haiku-4-5"
|
||||
|
||||
def test_preserve_false_mangles_as_documented(self):
|
||||
"""Canary: with ``preserve_dots=False`` the function still
|
||||
produces the broken all-hyphen form — this is the shape that
|
||||
Bedrock rejected and that the fix avoids. Keeping this test
|
||||
locks in the existing behaviour of ``normalize_model_name`` so a
|
||||
future refactor doesn't accidentally decouple the knob from its
|
||||
effect."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"global.anthropic.claude-opus-4-7", preserve_dots=False
|
||||
) == "global-anthropic-claude-opus-4-7"
|
||||
|
||||
def test_bare_foundation_model_id_preserved(self):
|
||||
"""Non-inference-profile Bedrock IDs
|
||||
(e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
|
||||
vendor separators and must also survive intact under
|
||||
``preserve_dots=True``."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
preserve_dots=True,
|
||||
) == "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
|
||||
|
||||
class TestBedrockBuildAnthropicKwargsEndToEnd:
|
||||
"""Integration: calling ``build_anthropic_kwargs`` with a Bedrock-
|
||||
shaped model ID and ``preserve_dots=True`` produces the unmangled
|
||||
model string in the outgoing kwargs — the exact body sent to the
|
||||
``bedrock-runtime.`` endpoint. This is the integration-level
|
||||
regression for the reporter's HTTP 400."""
|
||||
|
||||
def test_bedrock_inference_profile_survives_build_kwargs(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="global.anthropic.claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
preserve_dots=True,
|
||||
)
|
||||
assert kwargs["model"] == "global.anthropic.claude-opus-4-7", (
|
||||
"Bedrock inference-profile ID was mangled in build_anthropic_kwargs: "
|
||||
f"{kwargs['model']!r}"
|
||||
)
|
||||
|
||||
def test_bedrock_model_mangled_without_preserve_dots(self):
|
||||
"""Inverse canary: without the flag, ``build_anthropic_kwargs``
|
||||
still produces the broken form — so the fix in
|
||||
``_anthropic_preserve_dots`` is the load-bearing piece that
|
||||
wires ``preserve_dots=True`` through to this builder for the
|
||||
Bedrock case."""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="global.anthropic.claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
preserve_dots=False,
|
||||
)
|
||||
assert kwargs["model"] == "global-anthropic-claude-opus-4-7"
|
||||
253
tests/agent/test_codex_cloudflare_headers.py
Normal file
253
tests/agent/test_codex_cloudflare_headers.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Regression guard: Codex Cloudflare 403 mitigation headers.
|
||||
|
||||
The ``chatgpt.com/backend-api/codex`` endpoint sits behind a Cloudflare layer
|
||||
that whitelists a small set of first-party originators (``codex_cli_rs``,
|
||||
``codex_vscode``, ``codex_sdk_ts``, ``Codex*``). Requests from non-residential
|
||||
IPs (VPS, always-on servers, some corporate egress) that don't advertise an
|
||||
allowed originator are served 403 with ``cf-mitigated: challenge`` regardless
|
||||
of auth correctness.
|
||||
|
||||
``_codex_cloudflare_headers`` in ``agent.auxiliary_client`` centralizes the
|
||||
header set so the primary chat client (``run_agent.AIAgent.__init__`` +
|
||||
``_apply_client_headers_for_base_url``) and the auxiliary client paths
|
||||
(``_try_codex`` and the ``raw_codex`` branch of ``resolve_provider_client``)
|
||||
all emit the same headers.
|
||||
|
||||
These tests pin:
|
||||
- the originator value (must be ``codex_cli_rs`` — the whitelisted one)
|
||||
- the User-Agent shape (codex_cli_rs-prefixed)
|
||||
- ``ChatGPT-Account-ID`` extraction from the OAuth JWT (canonical casing,
|
||||
from codex-rs ``auth.rs``)
|
||||
- graceful handling of malformed tokens (drop the account-ID header, don't
|
||||
raise)
|
||||
- primary-client wiring at both entry points in ``run_agent.py``
|
||||
- aux-client wiring at both entry points in ``agent/auxiliary_client.py``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_codex_jwt(account_id: str = "acct-test-123") -> str:
|
||||
"""Build a syntactically valid Codex-style JWT with the account_id claim."""
|
||||
def b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
header = b64url(b'{"alg":"RS256","typ":"JWT"}')
|
||||
claims = {
|
||||
"sub": "user-xyz",
|
||||
"exp": 9999999999,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_plan_type": "plus",
|
||||
},
|
||||
}
|
||||
payload = b64url(json.dumps(claims).encode())
|
||||
sig = b64url(b"fake-sig")
|
||||
return f"{header}.{payload}.{sig}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _codex_cloudflare_headers — the shared helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodexCloudflareHeaders:
|
||||
def test_originator_is_codex_cli_rs(self):
|
||||
"""Cloudflare whitelists codex_cli_rs — any other value is 403'd."""
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
headers = _codex_cloudflare_headers(_make_codex_jwt())
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
|
||||
def test_user_agent_advertises_codex_cli_rs(self):
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
headers = _codex_cloudflare_headers(_make_codex_jwt())
|
||||
assert headers["User-Agent"].startswith("codex_cli_rs/")
|
||||
|
||||
def test_account_id_extracted_from_jwt(self):
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
headers = _codex_cloudflare_headers(_make_codex_jwt("acct-abc-999"))
|
||||
# Canonical casing — matches codex-rs auth.rs
|
||||
assert headers["ChatGPT-Account-ID"] == "acct-abc-999"
|
||||
|
||||
def test_canonical_header_casing(self):
|
||||
"""Upstream codex-rs uses PascalCase with trailing -ID. Match exactly."""
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
headers = _codex_cloudflare_headers(_make_codex_jwt())
|
||||
assert "ChatGPT-Account-ID" in headers
|
||||
# The lowercase/titlecase variants MUST NOT be used — pin to be explicit
|
||||
assert "chatgpt-account-id" not in headers
|
||||
assert "ChatGPT-Account-Id" not in headers
|
||||
|
||||
def test_malformed_token_drops_account_id_without_raising(self):
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
for bad in ["not-a-jwt", "", "only.one", " ", "...."]:
|
||||
headers = _codex_cloudflare_headers(bad)
|
||||
# Still returns base headers — never raises
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert "ChatGPT-Account-ID" not in headers
|
||||
|
||||
def test_non_string_token_handled(self):
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
headers = _codex_cloudflare_headers(None) # type: ignore[arg-type]
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert "ChatGPT-Account-ID" not in headers
|
||||
|
||||
def test_jwt_without_chatgpt_account_id_claim(self):
|
||||
"""A valid JWT that lacks the account_id claim should still return headers."""
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
import base64 as _b64, json as _json
|
||||
|
||||
def b64url(data: bytes) -> str:
|
||||
return _b64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
payload = b64url(_json.dumps({"sub": "user-xyz", "exp": 9999999999}).encode())
|
||||
token = f"{b64url(b'{}')}.{payload}.{b64url(b'sig')}"
|
||||
headers = _codex_cloudflare_headers(token)
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert "ChatGPT-Account-ID" not in headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary chat client wiring (run_agent.AIAgent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPrimaryClientWiring:
|
||||
def test_init_wires_codex_headers_for_chatgpt_base_url(self):
|
||||
from run_agent import AIAgent
|
||||
token = _make_codex_jwt("acct-primary-init")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
AIAgent(
|
||||
api_key=token,
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
provider="openai-codex",
|
||||
model="gpt-5.4",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
|
||||
assert headers.get("originator") == "codex_cli_rs"
|
||||
assert headers.get("ChatGPT-Account-ID") == "acct-primary-init"
|
||||
assert headers.get("User-Agent", "").startswith("codex_cli_rs/")
|
||||
|
||||
def test_apply_client_headers_on_base_url_change(self):
|
||||
"""Credential-rotation / base-url change path must also emit codex headers."""
|
||||
from run_agent import AIAgent
|
||||
token = _make_codex_jwt("acct-rotation")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
agent = AIAgent(
|
||||
api_key="placeholder-openrouter-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
# Simulate rotation into a Codex credential
|
||||
agent._client_kwargs["api_key"] = token
|
||||
agent._apply_client_headers_for_base_url(
|
||||
"https://chatgpt.com/backend-api/codex"
|
||||
)
|
||||
headers = agent._client_kwargs.get("default_headers") or {}
|
||||
assert headers.get("originator") == "codex_cli_rs"
|
||||
assert headers.get("ChatGPT-Account-ID") == "acct-rotation"
|
||||
assert headers.get("User-Agent", "").startswith("codex_cli_rs/")
|
||||
|
||||
def test_apply_client_headers_clears_codex_headers_off_chatgpt(self):
|
||||
"""Switching AWAY from chatgpt.com must drop the codex headers."""
|
||||
from run_agent import AIAgent
|
||||
token = _make_codex_jwt()
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
agent = AIAgent(
|
||||
api_key=token,
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
provider="openai-codex",
|
||||
model="gpt-5.4",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
# Sanity: headers are set initially
|
||||
assert "originator" in (agent._client_kwargs.get("default_headers") or {})
|
||||
agent._apply_client_headers_for_base_url(
|
||||
"https://api.anthropic.com"
|
||||
)
|
||||
# default_headers should be popped for anthropic base
|
||||
assert "default_headers" not in agent._client_kwargs
|
||||
|
||||
def test_openrouter_base_url_does_not_get_codex_headers(self):
|
||||
from run_agent import AIAgent
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
AIAgent(
|
||||
api_key="sk-or-test",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
|
||||
assert headers.get("originator") != "codex_cli_rs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auxiliary client wiring (agent.auxiliary_client)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuxiliaryClientWiring:
|
||||
def test_try_codex_passes_codex_headers(self, monkeypatch):
|
||||
"""_try_codex builds the OpenAI client used for compression / vision /
|
||||
title generation when routed through Codex. Must emit codex headers."""
|
||||
from agent import auxiliary_client
|
||||
token = _make_codex_jwt("acct-aux-try-codex")
|
||||
|
||||
# Force _select_pool_entry to return "no pool" so we fall through to
|
||||
# _read_codex_access_token.
|
||||
monkeypatch.setattr(
|
||||
auxiliary_client, "_select_pool_entry",
|
||||
lambda provider: (False, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auxiliary_client, "_read_codex_access_token",
|
||||
lambda: token,
|
||||
)
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
client, model = auxiliary_client._try_codex()
|
||||
assert client is not None
|
||||
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
|
||||
assert headers.get("originator") == "codex_cli_rs"
|
||||
assert headers.get("ChatGPT-Account-ID") == "acct-aux-try-codex"
|
||||
assert headers.get("User-Agent", "").startswith("codex_cli_rs/")
|
||||
|
||||
def test_resolve_provider_client_raw_codex_passes_codex_headers(self, monkeypatch):
|
||||
"""The ``raw_codex=True`` branch (used by the main agent loop for direct
|
||||
responses.stream() access) must also emit codex headers."""
|
||||
from agent import auxiliary_client
|
||||
token = _make_codex_jwt("acct-aux-raw-codex")
|
||||
monkeypatch.setattr(
|
||||
auxiliary_client, "_read_codex_access_token",
|
||||
lambda: token,
|
||||
)
|
||||
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
client, model = auxiliary_client.resolve_provider_client(
|
||||
"openai-codex", raw_codex=True,
|
||||
)
|
||||
assert client is not None
|
||||
headers = mock_openai.call_args.kwargs.get("default_headers") or {}
|
||||
assert headers.get("originator") == "codex_cli_rs"
|
||||
assert headers.get("ChatGPT-Account-ID") == "acct-aux-raw-codex"
|
||||
assert headers.get("User-Agent", "").startswith("codex_cli_rs/")
|
||||
@@ -781,3 +781,127 @@ class TestTokenBudgetTailProtection:
|
||||
# Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4)
|
||||
# so it might or might not be pruned depending on boundary
|
||||
assert isinstance(pruned, int)
|
||||
|
||||
|
||||
class TestTruncateToolCallArgsJson:
|
||||
"""Regression tests for #11762.
|
||||
|
||||
The previous implementation produced invalid JSON by slicing
|
||||
``function.arguments`` mid-string, which caused non-retryable 400s from
|
||||
strict providers (observed on MiniMax) and stuck long sessions in a
|
||||
re-send loop. The helper here must always emit parseable JSON whose
|
||||
shape matches the original — shrunken, not corrupted.
|
||||
"""
|
||||
|
||||
def _helper(self):
|
||||
from agent.context_compressor import _truncate_tool_call_args_json
|
||||
return _truncate_tool_call_args_json
|
||||
|
||||
def test_shrunken_args_remain_valid_json(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
original = _json.dumps({
|
||||
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
|
||||
"content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400,
|
||||
})
|
||||
assert len(original) > 500
|
||||
shrunk = shrink(original)
|
||||
parsed = _json.loads(shrunk) # must not raise
|
||||
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
|
||||
assert parsed["content"].endswith("...[truncated]")
|
||||
assert len(shrunk) < len(original)
|
||||
|
||||
def test_non_json_arguments_pass_through(self):
|
||||
shrink = self._helper()
|
||||
not_json = "this is not json at all, " * 50
|
||||
assert shrink(not_json) == not_json
|
||||
|
||||
def test_short_string_leaves_unchanged(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
payload = _json.dumps({"command": "ls -la", "cwd": "/tmp"})
|
||||
assert _json.loads(shrink(payload)) == {"command": "ls -la", "cwd": "/tmp"}
|
||||
|
||||
def test_nested_structures_are_walked(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
payload = _json.dumps({
|
||||
"messages": [
|
||||
{"role": "user", "content": "x" * 500},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
"meta": {"note": "y" * 500},
|
||||
})
|
||||
parsed = _json.loads(shrink(payload))
|
||||
assert parsed["messages"][0]["content"].endswith("...[truncated]")
|
||||
assert parsed["messages"][1]["content"] == "ok"
|
||||
assert parsed["meta"]["note"].endswith("...[truncated]")
|
||||
|
||||
def test_non_string_leaves_preserved(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
payload = _json.dumps({
|
||||
"retries": 3,
|
||||
"enabled": True,
|
||||
"timeout": None,
|
||||
"items": [1, 2, 3],
|
||||
"note": "z" * 500,
|
||||
})
|
||||
parsed = _json.loads(shrink(payload))
|
||||
assert parsed["retries"] == 3
|
||||
assert parsed["enabled"] is True
|
||||
assert parsed["timeout"] is None
|
||||
assert parsed["items"] == [1, 2, 3]
|
||||
assert parsed["note"].endswith("...[truncated]")
|
||||
|
||||
def test_scalar_json_string_gets_shrunk(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
payload = _json.dumps("q" * 500)
|
||||
parsed = _json.loads(shrink(payload))
|
||||
assert isinstance(parsed, str)
|
||||
assert parsed.endswith("...[truncated]")
|
||||
|
||||
def test_unicode_preserved(self):
|
||||
import json as _json
|
||||
shrink = self._helper()
|
||||
payload = _json.dumps({"content": "非德满" + ("a" * 500)})
|
||||
out = shrink(payload)
|
||||
# ensure_ascii=False keeps CJK intact rather than emitting \uXXXX
|
||||
assert "非德满" in out
|
||||
|
||||
def test_pass3_emits_valid_json_for_downstream_provider(self):
|
||||
"""End-to-end: Pass 3 must never produce the exact failure payload
|
||||
that caused the 400 loop (unterminated string, missing brace)."""
|
||||
import json as _json
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.85,
|
||||
protect_first_n=1,
|
||||
protect_last_n=1,
|
||||
quiet_mode=True,
|
||||
)
|
||||
huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400
|
||||
args_payload = _json.dumps({
|
||||
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
|
||||
"content": huge_content,
|
||||
})
|
||||
assert len(args_payload) > 500 # triggers the Pass-3 shrink
|
||||
messages = [
|
||||
{"role": "user", "content": "please write two files"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "call_1", "type": "function",
|
||||
"function": {"name": "write_file", "arguments": args_payload}},
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "call_1",
|
||||
"content": '{"bytes_written": 727}'},
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "done"},
|
||||
]
|
||||
result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)
|
||||
shrunk = result[1]["tool_calls"][0]["function"]["arguments"]
|
||||
# Must parse — otherwise downstream provider returns 400
|
||||
parsed = _json.loads(shrunk)
|
||||
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
|
||||
assert parsed["content"].endswith("...[truncated]")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -124,6 +125,31 @@ def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
real_run = subprocess.run
|
||||
|
||||
def blocked_rg(*args, **kwargs):
|
||||
cmd = args[0] if args else kwargs.get("args")
|
||||
if isinstance(cmd, list) and cmd and cmd[0] == "rg":
|
||||
raise PermissionError("rg blocked by policy")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
with patch("agent.context_references.subprocess.run", side_effect=blocked_rg):
|
||||
result = preprocess_context_references(
|
||||
"Review @folder:src/",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "src/" in result.message
|
||||
assert "main.py" in result.message
|
||||
assert "helper.py" in result.message
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_quoted_file_reference_with_spaces(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
|
||||
@@ -252,6 +252,11 @@ def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch):
|
||||
|
||||
def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
# Prevent auto-seeding from Codex CLI tokens on the host
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._import_codex_cli_tokens",
|
||||
lambda: None,
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -1091,6 +1096,7 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source == "gh_cli"
|
||||
assert entries[0].access_token == "gho_fake_token_abc123"
|
||||
assert entries[0].base_url == "https://api.githubcopilot.com"
|
||||
|
||||
|
||||
def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch):
|
||||
|
||||
@@ -1,129 +1,25 @@
|
||||
"""Tests for credential pool preservation through smart routing and 429 recovery.
|
||||
"""Tests for credential pool preservation through turn config and 429 recovery.
|
||||
|
||||
Covers:
|
||||
1. credential_pool flows through resolve_turn_route (no-route and fallback paths)
|
||||
2. CLI _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
4. Eager fallback deferred when credential pool has credentials
|
||||
5. Eager fallback fires when no credential pool exists
|
||||
6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
|
||||
1. CLI _resolve_turn_agent_config passes credential_pool to runtime dict
|
||||
2. Gateway _resolve_turn_agent_config passes credential_pool to runtime dict
|
||||
3. Eager fallback deferred when credential pool has credentials
|
||||
4. Eager fallback fires when no credential pool exists
|
||||
5. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. smart_model_routing: credential_pool preserved in no-route path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmartRoutingPoolPreservation:
|
||||
def test_no_route_preserves_credential_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing disabled
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_no_route_none_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is None
|
||||
|
||||
def test_routing_disabled_preserves_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing explicitly disabled
|
||||
result = resolve_turn_route("hello", {"enabled": False}, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch):
|
||||
"""When smart routing picks a cheap model but resolve_runtime_provider
|
||||
fails, the fallback to primary must still include credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
routing_config = {
|
||||
"enabled": True,
|
||||
"cheap_model": "openai/gpt-4.1-mini",
|
||||
"cheap_provider": "openrouter",
|
||||
"max_tokens": 200,
|
||||
"patterns": ["^(hi|hello|hey)"],
|
||||
}
|
||||
# Force resolve_runtime_provider to fail so it falls back to primary
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
MagicMock(side_effect=RuntimeError("no credentials")),
|
||||
)
|
||||
result = resolve_turn_route("hi", routing_config, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool
|
||||
# 1. CLI _resolve_turn_agent_config includes credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCliTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path):
|
||||
"""CLI's _resolve_turn_agent_config must pass credential_pool to primary."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
# Build a minimal HermesCLI-like object with the method
|
||||
def test_resolve_turn_includes_pool(self):
|
||||
"""CLI's _resolve_turn_agent_config must pass credential_pool in runtime."""
|
||||
fake_pool = MagicMock(name="FakePool")
|
||||
shell = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="sk-test",
|
||||
@@ -132,58 +28,46 @@ class TestCliTurnRoutePool:
|
||||
api_mode="codex_responses",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=MagicMock(name="FakePool"),
|
||||
_smart_model_routing={"enabled": False},
|
||||
_credential_pool=fake_pool,
|
||||
service_tier=None,
|
||||
)
|
||||
|
||||
# Import and bind the real method
|
||||
from cli import HermesCLI
|
||||
bound = HermesCLI._resolve_turn_agent_config.__get__(shell)
|
||||
bound("test message")
|
||||
route = bound("test message")
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is shell._credential_pool
|
||||
assert route["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Gateway _resolve_turn_agent_config includes credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch):
|
||||
def test_resolve_turn_includes_pool(self):
|
||||
"""Gateway's _resolve_turn_agent_config must pass credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = SimpleNamespace(
|
||||
_smart_model_routing={"enabled": False},
|
||||
)
|
||||
|
||||
fake_pool = MagicMock(name="FakePool")
|
||||
runner = SimpleNamespace(_service_tier=None)
|
||||
runtime_kwargs = {
|
||||
"api_key": "sk-test",
|
||||
"api_key": "***",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": MagicMock(name="FakePool"),
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
|
||||
bound = GatewayRunner._resolve_turn_agent_config.__get__(runner)
|
||||
bound("test message", "gpt-5.4", runtime_kwargs)
|
||||
route = bound("test message", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"]
|
||||
assert route["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 & 5. Eager fallback deferred/fires based on credential pool
|
||||
# 3 & 4. Eager fallback deferred/fires based on credential pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEagerFallbackWithPool:
|
||||
@@ -251,7 +135,7 @@ class TestEagerFallbackWithPool:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Full 429 rotation cycle via _recover_with_credential_pool
|
||||
# 5. Full 429 rotation cycle via _recover_with_credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPoolRotationCycle:
|
||||
|
||||
@@ -83,6 +83,13 @@ class TestBuildToolPreview:
|
||||
assert result is not None
|
||||
assert "user" in result
|
||||
|
||||
def test_memory_replace_missing_old_text_marked(self):
|
||||
# Avoid empty quotes "" in the preview when old_text is missing/None.
|
||||
result = build_tool_preview("memory", {"action": "replace", "target": "memory"})
|
||||
assert result == '~memory: "<missing old_text>"'
|
||||
result = build_tool_preview("memory", {"action": "remove", "target": "memory", "old_text": None})
|
||||
assert result == '-memory: "<missing old_text>"'
|
||||
|
||||
def test_session_search_preview(self):
|
||||
result = build_tool_preview("session_search", {"query": "find something"})
|
||||
assert result is not None
|
||||
|
||||
@@ -849,3 +849,97 @@ class TestAdversarialEdgeCases:
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
|
||||
# ── Regression: dict-typed message field (Issue #11233) ──
|
||||
|
||||
def test_pydantic_dict_message_no_crash(self):
|
||||
"""Pydantic validation errors return message as dict, not string.
|
||||
|
||||
Regression: classify_api_error must not crash when body['message']
|
||||
is a dict (e.g. {"detail": [...]} from FastAPI/Pydantic). The
|
||||
'or ""' fallback only handles None/falsy values — a non-empty
|
||||
dict is truthy and passed to .lower(), causing AttributeError.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Unprocessable Entity",
|
||||
status_code=422,
|
||||
body={
|
||||
"object": "error",
|
||||
"message": {
|
||||
"detail": [
|
||||
{
|
||||
"type": "extra_forbidden",
|
||||
"loc": ["body", "think"],
|
||||
"msg": "Extra inputs are not permitted",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.status_code == 422
|
||||
assert result.retryable is False
|
||||
|
||||
def test_nested_error_dict_message_no_crash(self):
|
||||
"""Nested body['error']['message'] as dict must not crash.
|
||||
|
||||
Some providers wrap Pydantic errors in an 'error' object.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Validation error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": {
|
||||
"detail": [
|
||||
{"type": "missing", "loc": ["body", "required"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=1000)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.status_code == 400
|
||||
|
||||
def test_metadata_raw_dict_message_no_crash(self):
|
||||
"""OpenRouter metadata.raw with dict message must not crash."""
|
||||
e = MockAPIError(
|
||||
"Provider error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": "Provider error",
|
||||
"metadata": {
|
||||
"raw": '{"error":{"message":{"detail":[{"type":"invalid"}]}}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
|
||||
# Broader non-string type guards — defense against other provider quirks.
|
||||
|
||||
def test_list_message_no_crash(self):
|
||||
"""Some providers return message as a list of error entries."""
|
||||
e = MockAPIError(
|
||||
"validation",
|
||||
status_code=400,
|
||||
body={"message": [{"msg": "field required"}]},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
def test_int_message_no_crash(self):
|
||||
"""Any non-string type must be coerced safely."""
|
||||
e = MockAPIError("server error", status_code=500, body={"message": 42})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
def test_none_message_still_works(self):
|
||||
"""Regression: None fallback (the 'or \"\"' path) must still work."""
|
||||
e = MockAPIError("server error", status_code=500, body={"message": None})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
1198
tests/agent/test_gemini_cloudcode.py
Normal file
1198
tests/agent/test_gemini_cloudcode.py
Normal file
File diff suppressed because it is too large
Load Diff
315
tests/agent/test_gemini_native_adapter.py
Normal file
315
tests/agent/test_gemini_native_adapter.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Tests for the native Google AI Studio Gemini adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, status_code=200, payload=None, headers=None, text=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.headers = headers or {}
|
||||
self.text = text if text is not None else json.dumps(self._payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_build_native_request_preserves_thought_signature_on_tool_replay():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{"role": "system", "content": "Be helpful."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
"extra_content": {
|
||||
"google": {"thought_signature": "sig-123"}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
parts = request["contents"][0]["parts"]
|
||||
assert parts[0]["functionCall"]["name"] == "get_weather"
|
||||
assert parts[0]["thoughtSignature"] == "sig-123"
|
||||
|
||||
|
||||
def test_build_native_request_uses_original_function_name_for_tool_result():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": '{"forecast": "sunny"}',
|
||||
},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
tool_response = request["contents"][1]["parts"][0]["functionResponse"]
|
||||
assert tool_response["name"] == "get_weather"
|
||||
|
||||
|
||||
def test_build_native_request_strips_json_schema_only_fields_from_tool_parameters():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_weather",
|
||||
"description": "Weather lookup",
|
||||
"parameters": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"$schema": "ignored",
|
||||
"description": "City name",
|
||||
}
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
params = request["tools"][0]["functionDeclarations"][0]["parameters"]
|
||||
assert "$schema" not in params
|
||||
assert "additionalProperties" not in params
|
||||
assert params["type"] == "object"
|
||||
assert params["properties"]["city"] == {
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
}
|
||||
|
||||
|
||||
def test_translate_native_response_surfaces_reasoning_and_tool_calls():
|
||||
from agent.gemini_native_adapter import translate_gemini_response
|
||||
|
||||
payload = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"thought": True, "text": "thinking..."},
|
||||
{"functionCall": {"name": "search", "args": {"q": "hermes"}}},
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5,
|
||||
"totalTokenCount": 15,
|
||||
},
|
||||
}
|
||||
|
||||
response = translate_gemini_response(payload, model="gemini-2.5-flash")
|
||||
choice = response.choices[0]
|
||||
assert choice.finish_reason == "tool_calls"
|
||||
assert choice.message.reasoning == "thinking..."
|
||||
assert choice.message.tool_calls[0].function.name == "search"
|
||||
assert json.loads(choice.message.tool_calls[0].function.arguments) == {"q": "hermes"}
|
||||
|
||||
|
||||
def test_native_client_uses_x_goog_api_key_and_native_models_endpoint(monkeypatch):
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
recorded = {}
|
||||
|
||||
class DummyHTTP:
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
recorded["url"] = url
|
||||
recorded["json"] = json
|
||||
recorded["headers"] = headers
|
||||
return DummyResponse(
|
||||
payload={
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "hello"}]},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 1,
|
||||
"candidatesTokenCount": 1,
|
||||
"totalTokenCount": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("agent.gemini_native_adapter.httpx.Client", lambda *a, **k: DummyHTTP())
|
||||
|
||||
client = GeminiNativeClient(api_key="AIza-test", base_url="https://generativelanguage.googleapis.com/v1beta")
|
||||
response = client.chat.completions.create(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
|
||||
assert recorded["url"] == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
|
||||
assert recorded["headers"]["x-goog-api-key"] == "AIza-test"
|
||||
assert "Authorization" not in recorded["headers"]
|
||||
assert response.choices[0].message.content == "hello"
|
||||
|
||||
|
||||
def test_native_http_error_keeps_status_and_retry_after():
|
||||
from agent.gemini_native_adapter import gemini_http_error
|
||||
|
||||
response = DummyResponse(
|
||||
status_code=429,
|
||||
headers={"Retry-After": "17"},
|
||||
payload={
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exhausted",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"reason": "RESOURCE_EXHAUSTED",
|
||||
"metadata": {"service": "generativelanguage.googleapis.com"},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
err = gemini_http_error(response)
|
||||
assert getattr(err, "status_code", None) == 429
|
||||
assert getattr(err, "retry_after", None) == 17.0
|
||||
assert "quota exhausted" in str(err)
|
||||
|
||||
|
||||
def test_native_client_accepts_injected_http_client():
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
injected = SimpleNamespace(close=lambda: None)
|
||||
client = GeminiNativeClient(api_key="AIza-test", http_client=injected)
|
||||
assert client._http is injected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_native_client_streams_without_requiring_async_iterator_from_sync_client():
|
||||
from agent.gemini_native_adapter import AsyncGeminiNativeClient
|
||||
|
||||
chunk = SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="hi"), finish_reason=None)])
|
||||
sync_stream = iter([chunk])
|
||||
|
||||
def _advance(iterator):
|
||||
try:
|
||||
return False, next(iterator)
|
||||
except StopIteration:
|
||||
return True, None
|
||||
|
||||
sync_client = SimpleNamespace(
|
||||
api_key="AIza-test",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: sync_stream)),
|
||||
_advance_stream_iterator=_advance,
|
||||
close=lambda: None,
|
||||
)
|
||||
|
||||
async_client = AsyncGeminiNativeClient(sync_client)
|
||||
stream = await async_client.chat.completions.create(stream=True)
|
||||
collected = []
|
||||
async for item in stream:
|
||||
collected.append(item)
|
||||
assert collected == [chunk]
|
||||
|
||||
|
||||
def test_stream_event_translation_emits_tool_call_delta_with_stable_index():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
tool_call_indices = {}
|
||||
event = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
first = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices=tool_call_indices)
|
||||
second = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices=tool_call_indices)
|
||||
|
||||
assert first[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert second[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert first[0].choices[0].delta.tool_calls[0].id == second[0].choices[0].delta.tool_calls[0].id
|
||||
assert first[0].choices[0].delta.tool_calls[0].function.arguments == '{"q": "abc"}'
|
||||
assert second[0].choices[0].delta.tool_calls[0].function.arguments == ""
|
||||
assert first[-1].choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
event = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chunks = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices={})
|
||||
tool_chunks = [chunk for chunk in chunks if chunk.choices[0].delta.tool_calls]
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
@@ -462,8 +462,10 @@ class TestTerminalFormatting:
|
||||
|
||||
assert "Input tokens" in text
|
||||
assert "Output tokens" in text
|
||||
assert "Est. cost" in text
|
||||
assert "$" in text
|
||||
# Cost and cache metrics are intentionally hidden (pricing was unreliable).
|
||||
assert "Est. cost" not in text
|
||||
assert "Cache read" not in text
|
||||
assert "Cache write" not in text
|
||||
|
||||
def test_terminal_format_shows_platforms(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
@@ -482,8 +484,8 @@ class TestTerminalFormatting:
|
||||
|
||||
assert "█" in text # Bar chart characters
|
||||
|
||||
def test_terminal_format_shows_na_for_custom_models(self, db):
|
||||
"""Custom models should show N/A instead of fake cost."""
|
||||
def test_terminal_format_hides_cost_for_custom_models(self, db):
|
||||
"""Cost display is hidden entirely — custom models no longer show 'N/A' either."""
|
||||
db.create_session(session_id="s1", source="cli", model="my-custom-model")
|
||||
db.update_token_counts("s1", input_tokens=1000, output_tokens=500)
|
||||
db._conn.commit()
|
||||
@@ -492,8 +494,9 @@ class TestTerminalFormatting:
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_terminal(report)
|
||||
|
||||
assert "N/A" in text
|
||||
assert "custom/self-hosted" in text
|
||||
assert "N/A" not in text
|
||||
assert "custom/self-hosted" not in text
|
||||
assert "Cost" not in text
|
||||
|
||||
|
||||
class TestGatewayFormatting:
|
||||
@@ -512,7 +515,7 @@ class TestGatewayFormatting:
|
||||
|
||||
assert "**" in text # Markdown bold
|
||||
|
||||
def test_gateway_format_shows_cost(self, populated_db):
|
||||
def test_gateway_format_hides_cost(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
report = engine.generate(days=30)
|
||||
text = engine.format_gateway(report)
|
||||
@@ -520,6 +523,7 @@ class TestGatewayFormatting:
|
||||
assert "$" in text
|
||||
assert "Top Skills" in text
|
||||
assert "Est. cost" in text
|
||||
assert "cache" not in text.lower()
|
||||
|
||||
def test_gateway_format_shows_models(self, populated_db):
|
||||
engine = InsightsEngine(populated_db)
|
||||
|
||||
@@ -396,6 +396,108 @@ class TestPluginMemoryDiscovery:
|
||||
assert load_memory_provider("nonexistent_provider") is None
|
||||
|
||||
|
||||
class TestUserInstalledProviderDiscovery:
|
||||
"""Memory providers installed to $HERMES_HOME/plugins/ should be found.
|
||||
|
||||
Regression test for issues #4956 and #9099: load_memory_provider() and
|
||||
discover_memory_providers() only scanned the bundled plugins/memory/
|
||||
directory, ignoring user-installed plugins.
|
||||
"""
|
||||
|
||||
def _make_user_memory_plugin(self, tmp_path, name="myprovider"):
|
||||
"""Create a minimal user memory provider plugin."""
|
||||
plugin_dir = tmp_path / "plugins" / name
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from agent.memory_provider import MemoryProvider\n"
|
||||
"class MyProvider(MemoryProvider):\n"
|
||||
f" @property\n"
|
||||
f" def name(self): return {name!r}\n"
|
||||
" def is_available(self): return True\n"
|
||||
" def initialize(self, **kw): pass\n"
|
||||
" def sync_turn(self, *a, **kw): pass\n"
|
||||
" def get_tool_schemas(self): return []\n"
|
||||
" def handle_tool_call(self, *a, **kw): return '{}'\n"
|
||||
)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
f"name: {name}\ndescription: Test user provider\n"
|
||||
)
|
||||
return plugin_dir
|
||||
|
||||
def test_discover_finds_user_plugins(self, tmp_path, monkeypatch):
|
||||
"""discover_memory_providers() includes user-installed plugins."""
|
||||
from plugins.memory import discover_memory_providers, _get_user_plugins_dir
|
||||
self._make_user_memory_plugin(tmp_path, "myexternal")
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory._get_user_plugins_dir",
|
||||
lambda: tmp_path / "plugins",
|
||||
)
|
||||
providers = discover_memory_providers()
|
||||
names = [n for n, _, _ in providers]
|
||||
assert "myexternal" in names
|
||||
assert "holographic" in names # bundled still found
|
||||
|
||||
def test_load_user_plugin(self, tmp_path, monkeypatch):
|
||||
"""load_memory_provider() can load from $HERMES_HOME/plugins/."""
|
||||
from plugins.memory import load_memory_provider
|
||||
self._make_user_memory_plugin(tmp_path, "myexternal")
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory._get_user_plugins_dir",
|
||||
lambda: tmp_path / "plugins",
|
||||
)
|
||||
p = load_memory_provider("myexternal")
|
||||
assert p is not None
|
||||
assert p.name == "myexternal"
|
||||
assert p.is_available()
|
||||
|
||||
def test_bundled_takes_precedence(self, tmp_path, monkeypatch):
|
||||
"""Bundled provider wins when user plugin has the same name."""
|
||||
from plugins.memory import load_memory_provider, discover_memory_providers
|
||||
# Create user plugin named "holographic" (same as bundled)
|
||||
plugin_dir = tmp_path / "plugins" / "holographic"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from agent.memory_provider import MemoryProvider\n"
|
||||
"class Fake(MemoryProvider):\n"
|
||||
" @property\n"
|
||||
" def name(self): return 'holographic-FAKE'\n"
|
||||
" def is_available(self): return True\n"
|
||||
" def initialize(self, **kw): pass\n"
|
||||
" def sync_turn(self, *a, **kw): pass\n"
|
||||
" def get_tool_schemas(self): return []\n"
|
||||
" def handle_tool_call(self, *a, **kw): return '{}'\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory._get_user_plugins_dir",
|
||||
lambda: tmp_path / "plugins",
|
||||
)
|
||||
# Load should return bundled (name "holographic"), not user (name "holographic-FAKE")
|
||||
p = load_memory_provider("holographic")
|
||||
assert p is not None
|
||||
assert p.name == "holographic" # bundled wins
|
||||
|
||||
# discover should not duplicate
|
||||
providers = discover_memory_providers()
|
||||
holo_count = sum(1 for n, _, _ in providers if n == "holographic")
|
||||
assert holo_count == 1
|
||||
|
||||
def test_non_memory_user_plugins_excluded(self, tmp_path, monkeypatch):
|
||||
"""User plugins that don't reference MemoryProvider are skipped."""
|
||||
from plugins.memory import discover_memory_providers
|
||||
plugin_dir = tmp_path / "plugins" / "notmemory"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"def register(ctx):\n ctx.register_tool('foo', 'bar', {}, lambda: None)\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory._get_user_plugins_dir",
|
||||
lambda: tmp_path / "plugins",
|
||||
)
|
||||
providers = discover_memory_providers()
|
||||
names = [n for n, _, _ in providers]
|
||||
assert "notmemory" not in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sequential dispatch routing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -695,3 +797,214 @@ class TestMemoryContextFencing:
|
||||
fence_end = combined.index("</memory-context>")
|
||||
assert "Alice" in combined[fence_start:fence_end]
|
||||
assert combined.index("weather") < fence_start
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AIAgent.commit_memory_session — routes to MemoryManager.on_session_end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _CommitRecorder(FakeMemoryProvider):
|
||||
"""Provider that records on_session_end calls for assertions."""
|
||||
|
||||
def __init__(self, name="recorder"):
|
||||
super().__init__(name)
|
||||
self.end_calls = []
|
||||
|
||||
def on_session_end(self, messages):
|
||||
self.end_calls.append(list(messages or []))
|
||||
|
||||
|
||||
class TestCommitMemorySessionRouting:
|
||||
def test_on_session_end_fans_out(self):
|
||||
mgr = MemoryManager()
|
||||
builtin = _CommitRecorder("builtin")
|
||||
external = _CommitRecorder("openviking")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(external)
|
||||
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
mgr.on_session_end(msgs)
|
||||
|
||||
assert builtin.end_calls == [msgs]
|
||||
assert external.end_calls == [msgs]
|
||||
|
||||
def test_on_session_end_tolerates_failure(self):
|
||||
mgr = MemoryManager()
|
||||
builtin = FakeMemoryProvider("builtin")
|
||||
bad = _CommitRecorder("bad-provider")
|
||||
bad.on_session_end = lambda m: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(bad)
|
||||
|
||||
mgr.on_session_end([]) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_memory_write bridge — must fire from both concurrent AND sequential paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnMemoryWriteBridge:
|
||||
"""Verify that MemoryManager.on_memory_write is called when built-in
|
||||
memory writes happen. This is a regression test for #10174 where the
|
||||
sequential tool execution path (_execute_tool_calls_sequential) was
|
||||
missing the bridge call, so single memory tool calls never notified
|
||||
external memory providers.
|
||||
"""
|
||||
|
||||
def test_on_memory_write_add(self):
|
||||
"""on_memory_write fires for 'add' actions."""
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("ext")
|
||||
mgr.add_provider(p)
|
||||
|
||||
mgr.on_memory_write("add", "memory", "new fact")
|
||||
assert p.memory_writes == [("add", "memory", "new fact")]
|
||||
|
||||
def test_on_memory_write_replace(self):
|
||||
"""on_memory_write fires for 'replace' actions."""
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("ext")
|
||||
mgr.add_provider(p)
|
||||
|
||||
mgr.on_memory_write("replace", "user", "updated pref")
|
||||
assert p.memory_writes == [("replace", "user", "updated pref")]
|
||||
|
||||
def test_on_memory_write_remove_not_bridged(self):
|
||||
"""The bridge intentionally skips 'remove' — only add/replace notify."""
|
||||
# This tests the contract that run_agent.py checks:
|
||||
# function_args.get("action") in ("add", "replace")
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("ext")
|
||||
mgr.add_provider(p)
|
||||
|
||||
# Manager itself doesn't filter — run_agent.py does.
|
||||
# But providers should handle remove gracefully.
|
||||
mgr.on_memory_write("remove", "memory", "old fact")
|
||||
assert p.memory_writes == [("remove", "memory", "old fact")]
|
||||
|
||||
def test_memory_manager_tool_injection_deduplicates(self):
|
||||
"""Memory manager tools already in self.tools (from plugin registry)
|
||||
must not be appended again. Duplicate function names cause 400 errors
|
||||
on providers that enforce unique names (e.g. Xiaomi MiMo via Nous Portal).
|
||||
|
||||
Regression test for: duplicate mnemosyne_recall / mnemosyne_remember /
|
||||
mnemosyne_stats in tools array → 400 from Nous Portal.
|
||||
"""
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("ext", tools=[
|
||||
{"name": "ext_recall", "description": "Recall", "parameters": {}},
|
||||
{"name": "ext_remember", "description": "Remember", "parameters": {}},
|
||||
])
|
||||
mgr.add_provider(p)
|
||||
|
||||
# Simulate self.tools already containing one of the plugin tools
|
||||
# (as if it was registered via ctx.register_tool → get_tool_definitions)
|
||||
existing_tools = [
|
||||
{"type": "function", "function": {"name": "ext_recall", "description": "Recall (from registry)", "parameters": {}}},
|
||||
{"type": "function", "function": {"name": "web_search", "description": "Search", "parameters": {}}},
|
||||
]
|
||||
|
||||
# Apply the same dedup logic from run_agent.py __init__
|
||||
_existing_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in existing_tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in mgr.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_names:
|
||||
continue
|
||||
existing_tools.append({"type": "function", "function": _schema})
|
||||
if _tname:
|
||||
_existing_names.add(_tname)
|
||||
|
||||
# ext_recall should NOT be duplicated; ext_remember should be added
|
||||
tool_names = [t["function"]["name"] for t in existing_tools]
|
||||
assert tool_names.count("ext_recall") == 1, f"ext_recall duplicated: {tool_names}"
|
||||
assert tool_names.count("ext_remember") == 1
|
||||
assert tool_names.count("web_search") == 1
|
||||
assert len(existing_tools) == 3 # web_search + ext_recall + ext_remember
|
||||
|
||||
def test_on_memory_write_tolerates_provider_failure(self):
|
||||
"""If a provider's on_memory_write raises, others still get notified."""
|
||||
mgr = MemoryManager()
|
||||
bad = FakeMemoryProvider("builtin")
|
||||
bad.on_memory_write = MagicMock(side_effect=RuntimeError("boom"))
|
||||
good = FakeMemoryProvider("good")
|
||||
mgr.add_provider(bad)
|
||||
mgr.add_provider(good)
|
||||
|
||||
mgr.on_memory_write("add", "user", "test")
|
||||
# Good provider still received the call despite bad provider crashing
|
||||
assert good.memory_writes == [("add", "user", "test")]
|
||||
|
||||
|
||||
class TestHonchoCadenceTracking:
|
||||
"""Verify Honcho provider cadence gating depends on on_turn_start().
|
||||
|
||||
Bug: _turn_count was never updated because on_turn_start() was not called
|
||||
from run_conversation(). This meant cadence checks always passed (every
|
||||
turn fired both context refresh and dialectic). Fixed by calling
|
||||
on_turn_start(self._user_turn_count, msg) before prefetch_all().
|
||||
"""
|
||||
|
||||
def test_turn_count_updates_on_turn_start(self):
|
||||
"""on_turn_start sets _turn_count, enabling cadence math."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
p = HonchoMemoryProvider()
|
||||
assert p._turn_count == 0
|
||||
p.on_turn_start(1, "hello")
|
||||
assert p._turn_count == 1
|
||||
p.on_turn_start(5, "world")
|
||||
assert p._turn_count == 5
|
||||
|
||||
def test_queue_prefetch_respects_dialectic_cadence(self):
|
||||
"""With dialecticCadence=3, dialectic should skip turns 2 and 3."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
p = HonchoMemoryProvider()
|
||||
p._dialectic_cadence = 3
|
||||
p._recall_mode = "context"
|
||||
p._session_key = "test-session"
|
||||
# Simulate a manager that records prefetch calls
|
||||
class FakeManager:
|
||||
def prefetch_context(self, key, query=None):
|
||||
pass
|
||||
|
||||
p._manager = FakeManager()
|
||||
|
||||
# Simulate turn 1: last_dialectic_turn = -999, so (1 - (-999)) >= 3 -> fires
|
||||
p.on_turn_start(1, "turn 1")
|
||||
p._last_dialectic_turn = 1 # simulate it fired
|
||||
p._last_context_turn = 1
|
||||
|
||||
# Simulate turn 2: (2 - 1) = 1 < 3 -> should NOT fire dialectic
|
||||
p.on_turn_start(2, "turn 2")
|
||||
assert (p._turn_count - p._last_dialectic_turn) < p._dialectic_cadence
|
||||
|
||||
# Simulate turn 3: (3 - 1) = 2 < 3 -> should NOT fire dialectic
|
||||
p.on_turn_start(3, "turn 3")
|
||||
assert (p._turn_count - p._last_dialectic_turn) < p._dialectic_cadence
|
||||
|
||||
# Simulate turn 4: (4 - 1) = 3 >= 3 -> should fire dialectic
|
||||
p.on_turn_start(4, "turn 4")
|
||||
assert (p._turn_count - p._last_dialectic_turn) >= p._dialectic_cadence
|
||||
|
||||
def test_injection_frequency_first_turn_with_1indexed(self):
|
||||
"""injection_frequency='first-turn' must inject on turn 1 (1-indexed)."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
p = HonchoMemoryProvider()
|
||||
p._injection_frequency = "first-turn"
|
||||
|
||||
# Turn 1 should inject (not skip)
|
||||
p.on_turn_start(1, "first message")
|
||||
assert p._turn_count == 1
|
||||
# The guard is `_turn_count > 1`, so turn 1 passes through
|
||||
should_skip = p._injection_frequency == "first-turn" and p._turn_count > 1
|
||||
assert not should_skip, "First turn (turn 1) should NOT be skipped"
|
||||
|
||||
# Turn 2 should skip
|
||||
p.on_turn_start(2, "second message")
|
||||
should_skip = p._injection_frequency == "first-turn" and p._turn_count > 1
|
||||
assert should_skip, "Second turn (turn 2) SHOULD be skipped"
|
||||
|
||||
@@ -208,34 +208,81 @@ class TestMem0UserIdScoping:
|
||||
|
||||
|
||||
class TestHonchoUserIdScoping:
|
||||
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
|
||||
"""Verify Honcho plugin keeps runtime user scoping separate from config peer_name."""
|
||||
|
||||
def test_gateway_user_id_overrides_peer_name(self):
|
||||
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
|
||||
def test_gateway_user_id_is_passed_as_runtime_peer(self):
|
||||
"""Gateway user_id should scope Honcho sessions without mutating config peer_name."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
|
||||
# Create a mock config with NO explicit peer_name
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.enabled = True
|
||||
mock_cfg.api_key = "test-key"
|
||||
mock_cfg.base_url = None
|
||||
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
|
||||
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.recall_mode = "context"
|
||||
mock_cfg.context_tokens = None
|
||||
mock_cfg.raw = {}
|
||||
mock_cfg.dialectic_depth = 1
|
||||
mock_cfg.dialectic_depth_levels = None
|
||||
mock_cfg.init_on_session_start = False
|
||||
mock_cfg.ai_peer = "hermes"
|
||||
mock_cfg.resolve_session_name.return_value = "test-sess"
|
||||
mock_cfg.session_strategy = "shared"
|
||||
|
||||
with patch(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
return_value=mock_cfg,
|
||||
):
|
||||
), patch(
|
||||
"plugins.memory.honcho.client.get_honcho_client",
|
||||
return_value=MagicMock(),
|
||||
), patch(
|
||||
"plugins.memory.honcho.session.HonchoSessionManager",
|
||||
) as mock_manager_cls:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_or_create.return_value = MagicMock(messages=[])
|
||||
mock_manager_cls.return_value = mock_manager
|
||||
provider.initialize(
|
||||
session_id="test-sess",
|
||||
user_id="discord_user_789",
|
||||
platform="discord",
|
||||
)
|
||||
|
||||
# The config's peer_name should have been overridden with the user_id
|
||||
assert mock_cfg.peer_name == "discord_user_789"
|
||||
assert mock_cfg.peer_name == "static-user"
|
||||
assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "discord_user_789"
|
||||
|
||||
def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self):
|
||||
"""Session manager should isolate gateway users even when config peer_name is static."""
|
||||
from plugins.memory.honcho.session import HonchoSessionManager
|
||||
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.ai_peer = "hermes"
|
||||
mock_cfg.write_frequency = "sync"
|
||||
mock_cfg.dialectic_reasoning_level = "low"
|
||||
mock_cfg.dialectic_dynamic = True
|
||||
mock_cfg.dialectic_max_chars = 600
|
||||
mock_cfg.observation_mode = "directional"
|
||||
mock_cfg.user_observe_me = True
|
||||
mock_cfg.user_observe_others = True
|
||||
mock_cfg.ai_observe_me = True
|
||||
mock_cfg.ai_observe_others = True
|
||||
|
||||
manager = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=mock_cfg,
|
||||
runtime_user_peer_name="discord_user_789",
|
||||
)
|
||||
|
||||
with patch.object(manager, "_get_or_create_peer", return_value=MagicMock()), patch.object(
|
||||
manager,
|
||||
"_get_or_create_honcho_session",
|
||||
return_value=(MagicMock(), []),
|
||||
):
|
||||
session = manager.get_or_create("discord:channel-1")
|
||||
|
||||
assert session.user_peer_id == "discord_user_789"
|
||||
|
||||
def test_no_user_id_preserves_config_peer_name(self):
|
||||
"""Without user_id, the config peer_name should be preserved."""
|
||||
|
||||
@@ -113,8 +113,10 @@ class TestDefaultContextLengths:
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "claude" not in key:
|
||||
continue
|
||||
# Claude 4.6 models have 1M context
|
||||
if "4.6" in key or "4-6" in key:
|
||||
# Claude 4.6+ models (4.6 and 4.7) have 1M context at standard
|
||||
# API pricing (no long-context premium). Older Claude 4.x and
|
||||
# 3.x models cap at 200k.
|
||||
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7")):
|
||||
assert value == 1000000, f"{key} should be 1000000"
|
||||
else:
|
||||
assert value == 200000, f"{key} should be 200000"
|
||||
|
||||
253
tests/agent/test_nous_rate_guard.py
Normal file
253
tests/agent/test_nous_rate_guard.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Tests for agent/nous_rate_guard.py — cross-session Nous Portal rate limit guard."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rate_guard_env(tmp_path, monkeypatch):
|
||||
"""Isolate rate guard state to a temp directory."""
|
||||
hermes_home = str(tmp_path / ".hermes")
|
||||
os.makedirs(hermes_home, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", hermes_home)
|
||||
# Clear any cached module-level imports
|
||||
return hermes_home
|
||||
|
||||
|
||||
class TestRecordNousRateLimit:
|
||||
"""Test recording rate limit state."""
|
||||
|
||||
def test_records_with_header_reset(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "1800"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
path = _state_path()
|
||||
assert os.path.exists(path)
|
||||
with open(path) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(1800, abs=2)
|
||||
assert state["reset_at"] > time.time()
|
||||
|
||||
def test_records_with_per_minute_header(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"x-ratelimit-reset-requests": "45"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(45, abs=2)
|
||||
|
||||
def test_records_with_retry_after_header(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"retry-after": "60"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(60, abs=2)
|
||||
|
||||
def test_prefers_hourly_over_per_minute(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-reset-requests-1h": "1800",
|
||||
"x-ratelimit-reset-requests": "45",
|
||||
}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
# Should use the hourly value, not the per-minute one
|
||||
assert state["reset_seconds"] == pytest.approx(1800, abs=2)
|
||||
|
||||
def test_falls_back_to_error_context_reset_at(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
future_reset = time.time() + 900
|
||||
record_nous_rate_limit(
|
||||
headers=None,
|
||||
error_context={"reset_at": future_reset},
|
||||
)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_at"] == pytest.approx(future_reset, abs=1)
|
||||
|
||||
def test_falls_back_to_default_cooldown(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers=None)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
# Default is 300 seconds (5 minutes)
|
||||
assert state["reset_seconds"] == pytest.approx(300, abs=2)
|
||||
|
||||
def test_custom_default_cooldown(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers=None, default_cooldown=120.0)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(120, abs=2)
|
||||
|
||||
def test_creates_directory_if_missing(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers={"retry-after": "10"})
|
||||
assert os.path.exists(_state_path())
|
||||
|
||||
|
||||
class TestNousRateLimitRemaining:
|
||||
"""Test checking remaining rate limit time."""
|
||||
|
||||
def test_returns_none_when_no_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
|
||||
def test_returns_remaining_seconds_when_active(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, nous_rate_limit_remaining
|
||||
|
||||
record_nous_rate_limit(headers={"x-ratelimit-reset-requests-1h": "600"})
|
||||
remaining = nous_rate_limit_remaining()
|
||||
assert remaining is not None
|
||||
assert 595 < remaining <= 605 # ~600 seconds, allowing for test execution time
|
||||
|
||||
def test_returns_none_when_expired(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
|
||||
|
||||
# Write an already-expired state
|
||||
state_dir = os.path.dirname(_state_path())
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
with open(_state_path(), "w") as f:
|
||||
json.dump({"reset_at": time.time() - 10, "recorded_at": time.time() - 100}, f)
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
# File should be cleaned up
|
||||
assert not os.path.exists(_state_path())
|
||||
|
||||
def test_handles_corrupt_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
|
||||
|
||||
state_dir = os.path.dirname(_state_path())
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
with open(_state_path(), "w") as f:
|
||||
f.write("not valid json{{{")
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
|
||||
|
||||
class TestClearNousRateLimit:
|
||||
"""Test clearing rate limit state."""
|
||||
|
||||
def test_clears_existing_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import (
|
||||
record_nous_rate_limit,
|
||||
clear_nous_rate_limit,
|
||||
nous_rate_limit_remaining,
|
||||
_state_path,
|
||||
)
|
||||
|
||||
record_nous_rate_limit(headers={"retry-after": "600"})
|
||||
assert nous_rate_limit_remaining() is not None
|
||||
|
||||
clear_nous_rate_limit()
|
||||
assert nous_rate_limit_remaining() is None
|
||||
assert not os.path.exists(_state_path())
|
||||
|
||||
def test_clear_when_no_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import clear_nous_rate_limit
|
||||
|
||||
# Should not raise
|
||||
clear_nous_rate_limit()
|
||||
|
||||
|
||||
class TestFormatRemaining:
|
||||
"""Test human-readable duration formatting."""
|
||||
|
||||
def test_seconds(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(30) == "30s"
|
||||
|
||||
def test_minutes(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(125) == "2m 5s"
|
||||
|
||||
def test_exact_minutes(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(120) == "2m"
|
||||
|
||||
def test_hours(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(3720) == "1h 2m"
|
||||
|
||||
|
||||
class TestParseResetSeconds:
|
||||
"""Test header parsing for reset times."""
|
||||
|
||||
def test_case_insensitive_headers(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"X-Ratelimit-Reset-Requests-1h": "1200"}
|
||||
assert _parse_reset_seconds(headers) == 1200.0
|
||||
|
||||
def test_returns_none_for_empty_headers(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
assert _parse_reset_seconds(None) is None
|
||||
assert _parse_reset_seconds({}) is None
|
||||
|
||||
def test_ignores_zero_values(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "0"}
|
||||
assert _parse_reset_seconds(headers) is None
|
||||
|
||||
def test_ignores_invalid_values(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "not-a-number"}
|
||||
assert _parse_reset_seconds(headers) is None
|
||||
|
||||
|
||||
class TestAuxiliaryClientIntegration:
|
||||
"""Test that the auxiliary client respects the rate guard."""
|
||||
|
||||
def test_try_nous_skips_when_rate_limited(self, rate_guard_env, monkeypatch):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit
|
||||
|
||||
# Record a rate limit
|
||||
record_nous_rate_limit(headers={"retry-after": "600"})
|
||||
|
||||
# Mock _read_nous_auth to return valid creds (would normally succeed)
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(aux, "_read_nous_auth", lambda: {
|
||||
"access_token": "test-token",
|
||||
"inference_base_url": "https://api.nous.test/v1",
|
||||
})
|
||||
|
||||
result = aux._try_nous()
|
||||
assert result == (None, None)
|
||||
|
||||
def test_try_nous_works_when_not_rate_limited(self, rate_guard_env, monkeypatch):
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
# No rate limit recorded — _try_nous should proceed normally
|
||||
# (will return None because no real creds, but won't be blocked
|
||||
# by the rate guard)
|
||||
monkeypatch.setattr(aux, "_read_nous_auth", lambda: None)
|
||||
result = aux._try_nous()
|
||||
assert result == (None, None)
|
||||
@@ -354,6 +354,24 @@ class TestBuildSkillsSystemPrompt:
|
||||
assert "web-search" in result
|
||||
assert "old-tool" not in result
|
||||
|
||||
def test_rebuilds_prompt_when_disabled_skills_change(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
skill_dir = tmp_path / "skills" / "tools" / "cached-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: cached-skill\ndescription: Cached skill\n---\n"
|
||||
)
|
||||
|
||||
first = build_skills_system_prompt()
|
||||
assert "cached-skill" in first
|
||||
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"skills:\n disabled: [cached-skill]\n"
|
||||
)
|
||||
|
||||
second = build_skills_system_prompt()
|
||||
assert "cached-skill" not in second
|
||||
|
||||
def test_includes_setup_needed_skills(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False)
|
||||
@@ -413,7 +431,7 @@ class TestBuildSkillsSystemPrompt:
|
||||
|
||||
class TestBuildNousSubscriptionPrompt:
|
||||
def test_includes_active_subscription_features(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||
lambda config=None: NousSubscriptionFeatures(
|
||||
@@ -437,7 +455,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys" in prompt
|
||||
|
||||
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||
lambda config=None: NousSubscriptionFeatures(
|
||||
@@ -460,7 +478,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||
assert "Do not mention subscription unless" in prompt
|
||||
|
||||
def test_feature_flag_off_returns_empty_prompt(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: False)
|
||||
|
||||
prompt = build_nous_subscription_prompt({"web_search"})
|
||||
|
||||
|
||||
60
tests/agent/test_proxy_and_url_validation.py
Normal file
60
tests/agent/test_proxy_and_url_validation.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for malformed proxy env var and base URL validation.
|
||||
|
||||
Salvaged from PR #6403 by MestreY0d4-Uninter — validates that the agent
|
||||
surfaces clear errors instead of cryptic httpx ``Invalid port`` exceptions
|
||||
when proxy env vars or custom endpoint URLs are malformed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls
|
||||
|
||||
|
||||
# -- proxy env validation ------------------------------------------------
|
||||
|
||||
|
||||
def test_proxy_env_accepts_normal_values(monkeypatch):
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:6153")
|
||||
monkeypatch.setenv("HTTPS_PROXY", "https://proxy.example.com:8443")
|
||||
monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.1:1080")
|
||||
_validate_proxy_env_urls() # should not raise
|
||||
|
||||
|
||||
def test_proxy_env_accepts_empty(monkeypatch):
|
||||
monkeypatch.delenv("HTTP_PROXY", raising=False)
|
||||
monkeypatch.delenv("HTTPS_PROXY", raising=False)
|
||||
monkeypatch.delenv("ALL_PROXY", raising=False)
|
||||
monkeypatch.delenv("http_proxy", raising=False)
|
||||
monkeypatch.delenv("https_proxy", raising=False)
|
||||
monkeypatch.delenv("all_proxy", raising=False)
|
||||
_validate_proxy_env_urls() # should not raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
||||
"http_proxy", "https_proxy", "all_proxy",
|
||||
])
|
||||
def test_proxy_env_rejects_malformed_port(monkeypatch, key):
|
||||
monkeypatch.setenv(key, "http://127.0.0.1:6153export")
|
||||
with pytest.raises(RuntimeError, match=rf"Malformed proxy environment variable {key}=.*6153export"):
|
||||
_validate_proxy_env_urls()
|
||||
|
||||
|
||||
# -- base URL validation -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://api.example.com/v1",
|
||||
"http://127.0.0.1:6153/v1",
|
||||
"acp://copilot",
|
||||
"",
|
||||
None,
|
||||
])
|
||||
def test_base_url_accepts_valid(url):
|
||||
_validate_base_url(url) # should not raise
|
||||
|
||||
|
||||
def test_base_url_rejects_malformed_port():
|
||||
with pytest.raises(RuntimeError, match="Malformed custom endpoint URL"):
|
||||
_validate_base_url("http://127.0.0.1:6153export")
|
||||
@@ -284,3 +284,95 @@ class TestElevenLabsTavilyExaKeys:
|
||||
assert "XYZ789abcdef" not in result
|
||||
assert "HOME=/home/user" in result
|
||||
assert "SHELL=/bin/bash" in result
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""JWT tokens start with eyJ (base64 for '{') and have dot-separated parts."""
|
||||
|
||||
def test_full_3part_jwt(self):
|
||||
text = (
|
||||
"Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJpc3MiOiI0MjNiZDJkYjg4MjI0MDAwIn0"
|
||||
".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM"
|
||||
)
|
||||
result = redact_sensitive_text(text)
|
||||
assert "Token:" in result
|
||||
# Payload and signature must not survive
|
||||
assert "eyJpc3Mi" not in result
|
||||
assert "Gxgv0rru" not in result
|
||||
|
||||
def test_2part_jwt(self):
|
||||
text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "eyJzdWIi" not in result
|
||||
|
||||
def test_standalone_jwt_header(self):
|
||||
text = "leaked header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 here"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "IkpXVCJ9" not in result
|
||||
assert "leaked header:" in result
|
||||
|
||||
def test_jwt_with_base64_padding(self):
|
||||
text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=.abc123def456ghij"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456" not in result
|
||||
|
||||
def test_short_eyj_not_matched(self):
|
||||
"""eyJ followed by fewer than 10 base64 chars should not match."""
|
||||
text = "eyJust a normal word"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_jwt_preserves_surrounding_text(self):
|
||||
text = "before eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 after"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("before ")
|
||||
assert result.endswith(" after")
|
||||
|
||||
def test_home_assistant_jwt_in_memory(self):
|
||||
"""Real-world pattern: HA token stored in agent memory block."""
|
||||
text = (
|
||||
"Home Assistant API Token: "
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJpc3MiOiJhYmNkZWYiLCJleHAiOjE3NzQ5NTcxMDN9"
|
||||
".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM"
|
||||
)
|
||||
result = redact_sensitive_text(text)
|
||||
assert "Home Assistant API Token:" in result
|
||||
assert "Gxgv0rru" not in result
|
||||
assert "..." in result
|
||||
|
||||
|
||||
class TestDiscordMentions:
|
||||
"""Discord snowflake IDs in <@ID> or <@!ID> format."""
|
||||
|
||||
def test_normal_mention(self):
|
||||
result = redact_sensitive_text("Hello <@222589316709220353>")
|
||||
assert "222589316709220353" not in result
|
||||
assert "<@***>" in result
|
||||
|
||||
def test_nickname_mention(self):
|
||||
result = redact_sensitive_text("Ping <@!1331549159177846844>")
|
||||
assert "1331549159177846844" not in result
|
||||
assert "<@!***>" in result
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
text = "<@111111111111111111> and <@222222222222222222>"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "111111111111111111" not in result
|
||||
assert "222222222222222222" not in result
|
||||
|
||||
def test_short_id_not_matched(self):
|
||||
"""IDs shorter than 17 digits are not Discord snowflakes."""
|
||||
text = "<@12345>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_slack_mention_not_matched(self):
|
||||
"""Slack mentions use letters, not pure digits."""
|
||||
text = "<@U024BE7LH>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_preserves_surrounding_text(self):
|
||||
text = "User <@222589316709220353> said hello"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("User ")
|
||||
assert result.endswith(" said hello")
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
from agent.smart_model_routing import choose_cheap_model_route
|
||||
|
||||
|
||||
_BASE_CONFIG = {
|
||||
"enabled": True,
|
||||
"cheap_model": {
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_returns_none_when_disabled():
|
||||
cfg = {**_BASE_CONFIG, "enabled": False}
|
||||
assert choose_cheap_model_route("what time is it in tokyo?", cfg) is None
|
||||
|
||||
|
||||
def test_routes_short_simple_prompt():
|
||||
result = choose_cheap_model_route("what time is it in tokyo?", _BASE_CONFIG)
|
||||
assert result is not None
|
||||
assert result["provider"] == "openrouter"
|
||||
assert result["model"] == "google/gemini-2.5-flash"
|
||||
assert result["routing_reason"] == "simple_turn"
|
||||
|
||||
|
||||
def test_skips_long_prompt():
|
||||
prompt = "please summarize this carefully " * 20
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_skips_code_like_prompt():
|
||||
prompt = "debug this traceback: ```python\nraise ValueError('bad')\n```"
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_skips_tool_heavy_prompt_keywords():
|
||||
prompt = "implement a patch for this docker error"
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_resolve_turn_route_falls_back_to_primary_when_route_runtime_cannot_be_resolved(monkeypatch):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("bad route")),
|
||||
)
|
||||
result = resolve_turn_route(
|
||||
"what time is it in tokyo?",
|
||||
_BASE_CONFIG,
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"api_key": "sk-primary",
|
||||
},
|
||||
)
|
||||
assert result["model"] == "anthropic/claude-sonnet-4"
|
||||
assert result["runtime"]["provider"] == "openrouter"
|
||||
assert result["label"] is None
|
||||
@@ -79,7 +79,7 @@ class TestBuildChildProgressCallback:
|
||||
parent._delegate_spinner = None
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
assert cb is None
|
||||
|
||||
def test_cli_spinner_tool_event(self):
|
||||
@@ -93,7 +93,7 @@ class TestBuildChildProgressCallback:
|
||||
parent._delegate_spinner = spinner
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
assert cb is not None
|
||||
|
||||
cb("tool.started", "web_search", "quantum computing", {})
|
||||
@@ -113,7 +113,7 @@ class TestBuildChildProgressCallback:
|
||||
parent._delegate_spinner = spinner
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
cb("_thinking", "I'll search for papers first")
|
||||
|
||||
output = buf.getvalue()
|
||||
@@ -121,54 +121,64 @@ class TestBuildChildProgressCallback:
|
||||
assert "search for papers" in output
|
||||
|
||||
def test_gateway_batched_progress(self):
|
||||
"""Gateway path should batch tool calls and flush at BATCH_SIZE."""
|
||||
"""Gateway path: each tool.started relays a subagent.tool event, and a
|
||||
subagent.progress summary fires once BATCH_SIZE tools accumulate."""
|
||||
parent = MagicMock()
|
||||
parent._delegate_spinner = None
|
||||
parent_cb = MagicMock()
|
||||
parent.tool_progress_callback = parent_cb
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
|
||||
# Send 4 tool calls — shouldn't flush yet (BATCH_SIZE = 5)
|
||||
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
|
||||
# Each tool.started relays a subagent.tool event immediately (per-tool relay).
|
||||
for i in range(4):
|
||||
cb("tool.started", f"tool_{i}", f"arg_{i}", {})
|
||||
parent_cb.assert_not_called()
|
||||
|
||||
# 5th call should trigger flush
|
||||
cb("tool.started", "tool_4", "arg_4", {})
|
||||
parent_cb.assert_called_once()
|
||||
call_args = parent_cb.call_args
|
||||
assert "tool_0" in call_args[0][1]
|
||||
assert "tool_4" in call_args[0][1]
|
||||
# 4 per-tool relays so far, no batch summary yet (BATCH_SIZE=5)
|
||||
events = [c.args[0] for c in parent_cb.call_args_list]
|
||||
assert events == ["subagent.tool"] * 4
|
||||
|
||||
def test_thinking_not_relayed_to_gateway(self):
|
||||
"""Thinking events should NOT be sent to gateway (too noisy)."""
|
||||
# 5th call triggers another per-tool relay PLUS the batch-size summary
|
||||
cb("tool.started", "tool_4", "arg_4", {})
|
||||
events = [c.args[0] for c in parent_cb.call_args_list]
|
||||
assert events == ["subagent.tool"] * 5 + ["subagent.progress"]
|
||||
summary_call = parent_cb.call_args_list[-1]
|
||||
summary_text = summary_call.kwargs.get("preview") or summary_call.args[2]
|
||||
assert "tool_0" in summary_text
|
||||
assert "tool_4" in summary_text
|
||||
|
||||
def test_thinking_relayed_to_gateway(self):
|
||||
"""Thinking events are relayed as subagent.thinking events."""
|
||||
parent = MagicMock()
|
||||
parent._delegate_spinner = None
|
||||
parent_cb = MagicMock()
|
||||
parent.tool_progress_callback = parent_cb
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
cb("_thinking", "some reasoning text")
|
||||
|
||||
parent_cb.assert_not_called()
|
||||
|
||||
parent_cb.assert_called_once()
|
||||
assert parent_cb.call_args.args[0] == "subagent.thinking"
|
||||
assert parent_cb.call_args.args[2] == "some reasoning text"
|
||||
|
||||
def test_parallel_callbacks_independent(self):
|
||||
"""Each child's callback should have independent batch state."""
|
||||
"""Each child's callback batches tool names independently."""
|
||||
parent = MagicMock()
|
||||
parent._delegate_spinner = None
|
||||
parent_cb = MagicMock()
|
||||
parent.tool_progress_callback = parent_cb
|
||||
|
||||
cb0 = _build_child_progress_callback(0, parent)
|
||||
cb1 = _build_child_progress_callback(1, parent)
|
||||
|
||||
# Send 3 calls to each — neither should flush (batch size = 5)
|
||||
|
||||
cb0 = _build_child_progress_callback(0, "goal a", parent)
|
||||
cb1 = _build_child_progress_callback(1, "goal b", parent)
|
||||
|
||||
# 3 tool.started per child = 6 per-tool relays; neither should hit
|
||||
# the batch-size summary (batch size = 5, counted per-child).
|
||||
for i in range(3):
|
||||
cb0(f"tool_{i}")
|
||||
cb1(f"other_{i}")
|
||||
|
||||
parent_cb.assert_not_called()
|
||||
cb0("tool.started", f"tool_{i}", f"a_{i}", {})
|
||||
cb1("tool.started", f"other_{i}", f"b_{i}", {})
|
||||
|
||||
events = [c.args[0] for c in parent_cb.call_args_list]
|
||||
assert events.count("subagent.tool") == 6
|
||||
assert "subagent.progress" not in events
|
||||
|
||||
def test_task_index_prefix_in_batch_mode(self):
|
||||
"""Batch mode (task_count > 1) should show 1-indexed prefix for all tasks."""
|
||||
@@ -182,7 +192,7 @@ class TestBuildChildProgressCallback:
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
# task_index=0 in a batch of 3 → prefix "[1]"
|
||||
cb0 = _build_child_progress_callback(0, parent, task_count=3)
|
||||
cb0 = _build_child_progress_callback(0, "test goal", parent, task_count=3)
|
||||
cb0("web_search", "test")
|
||||
output = buf.getvalue()
|
||||
assert "[1]" in output
|
||||
@@ -190,7 +200,7 @@ class TestBuildChildProgressCallback:
|
||||
# task_index=2 in a batch of 3 → prefix "[3]"
|
||||
buf.truncate(0)
|
||||
buf.seek(0)
|
||||
cb2 = _build_child_progress_callback(2, parent, task_count=3)
|
||||
cb2 = _build_child_progress_callback(2, "test goal", parent, task_count=3)
|
||||
cb2("web_search", "test")
|
||||
output = buf.getvalue()
|
||||
assert "[3]" in output
|
||||
@@ -206,7 +216,7 @@ class TestBuildChildProgressCallback:
|
||||
parent._delegate_spinner = spinner
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
cb = _build_child_progress_callback(0, parent, task_count=1)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent, task_count=1)
|
||||
cb("tool.started", "web_search", "test", {})
|
||||
|
||||
output = buf.getvalue()
|
||||
@@ -321,26 +331,31 @@ class TestBatchFlush:
|
||||
"""Tests for gateway batch flush on subagent completion."""
|
||||
|
||||
def test_flush_sends_remaining_batch(self):
|
||||
"""_flush should send remaining tool names to gateway."""
|
||||
"""_flush should send a final subagent.progress summary of any unsent
|
||||
tool names in the batch (less than BATCH_SIZE)."""
|
||||
parent = MagicMock()
|
||||
parent._delegate_spinner = None
|
||||
parent_cb = MagicMock()
|
||||
parent.tool_progress_callback = parent_cb
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
|
||||
# Send 3 tools (below batch size of 5)
|
||||
# Send 3 tools (below batch size of 5) — each relays subagent.tool
|
||||
cb("tool.started", "web_search", "query1", {})
|
||||
cb("tool.started", "read_file", "file.txt", {})
|
||||
cb("tool.started", "write_file", "out.txt", {})
|
||||
parent_cb.assert_not_called()
|
||||
events = [c.args[0] for c in parent_cb.call_args_list]
|
||||
assert events == ["subagent.tool"] * 3 # per-tool relays so far
|
||||
assert "subagent.progress" not in events # no batch-size summary yet
|
||||
|
||||
# Flush should send the remaining 3
|
||||
# Flush should send the remaining 3 as a summary
|
||||
cb._flush()
|
||||
parent_cb.assert_called_once()
|
||||
summary = parent_cb.call_args[0][1]
|
||||
assert "web_search" in summary
|
||||
assert "write_file" in summary
|
||||
events = [c.args[0] for c in parent_cb.call_args_list]
|
||||
assert events[-1] == "subagent.progress"
|
||||
summary_call = parent_cb.call_args_list[-1]
|
||||
summary_text = summary_call.kwargs.get("preview") or summary_call.args[2]
|
||||
assert "web_search" in summary_text
|
||||
assert "write_file" in summary_text
|
||||
|
||||
def test_flush_noop_when_batch_empty(self):
|
||||
"""_flush should not send anything when batch is empty."""
|
||||
@@ -349,7 +364,7 @@ class TestBatchFlush:
|
||||
parent_cb = MagicMock()
|
||||
parent.tool_progress_callback = parent_cb
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
cb._flush()
|
||||
parent_cb.assert_not_called()
|
||||
|
||||
@@ -364,7 +379,7 @@ class TestBatchFlush:
|
||||
parent._delegate_spinner = spinner
|
||||
parent.tool_progress_callback = None
|
||||
|
||||
cb = _build_child_progress_callback(0, parent)
|
||||
cb = _build_child_progress_callback(0, "test goal", parent)
|
||||
cb("tool.started", "web_search", "test", {})
|
||||
cb._flush() # Should not crash
|
||||
|
||||
|
||||
40
tests/agent/test_vision_resolved_args.py
Normal file
40
tests/agent/test_vision_resolved_args.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Test that call_llm vision path passes resolved provider args, not raw ones."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def test_vision_call_uses_resolved_provider_args():
|
||||
"""Resolved provider/model/key/url from config must reach resolve_vision_provider_client."""
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="description"))],
|
||||
usage=MagicMock(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client.resolve_vision_provider_client",
|
||||
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
|
||||
) as mock_vision,
|
||||
):
|
||||
call_llm(
|
||||
"vision",
|
||||
provider="raw-provider",
|
||||
model="raw-model",
|
||||
base_url="http://raw",
|
||||
api_key="raw-key",
|
||||
messages=[{"role": "user", "content": "describe this"}],
|
||||
)
|
||||
|
||||
# The resolved values must be passed, not the raw call_llm arguments
|
||||
call_args = mock_vision.call_args
|
||||
assert call_args.kwargs["provider"] == "my-resolved-provider"
|
||||
assert call_args.kwargs["model"] == "my-resolved-model"
|
||||
assert call_args.kwargs["base_url"] == "http://resolved"
|
||||
assert call_args.kwargs["api_key"] == "resolved-key"
|
||||
@@ -141,3 +141,116 @@ class TestCliApprovalUi:
|
||||
assert "archive-" in rendered
|
||||
assert "keyring.gpg" in rendered
|
||||
assert "status=progress" in rendered
|
||||
|
||||
def test_approval_display_preserves_command_and_choices_with_long_description(self):
|
||||
"""Regression: long tirith descriptions used to push approve/deny off-screen.
|
||||
|
||||
The panel must always render the command and every choice, even when
|
||||
the description would otherwise wrap into 10+ lines. The description
|
||||
gets truncated with a marker instead.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
long_desc = (
|
||||
"Security scan — [CRITICAL] Destructive shell command with wildcard expansion: "
|
||||
"The command performs a recursive deletion of log files which may contain "
|
||||
"audit information relevant to active incident investigations, running services "
|
||||
"that rely on log files for state, rotated archives, and other system artifacts. "
|
||||
"Review whether this is intended before approving. Consider whether a targeted "
|
||||
"deletion with more specific filters would better match the intent."
|
||||
)
|
||||
cli._approval_state = {
|
||||
"command": "rm -rf /var/log/apache2/*.log",
|
||||
"description": long_desc,
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
# Simulate a compact terminal where the old unbounded panel would overflow.
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 20))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# Command must be fully visible (rm -rf /var/log/apache2/*.log is short).
|
||||
assert "rm -rf /var/log/apache2/*.log" in rendered
|
||||
|
||||
# Every choice must render — this is the core bug: approve/deny were
|
||||
# getting clipped off the bottom of the panel.
|
||||
assert "Allow once" in rendered
|
||||
assert "Allow for this session" in rendered
|
||||
assert "Add to permanent allowlist" in rendered
|
||||
assert "Deny" in rendered
|
||||
|
||||
# The bottom border must render (i.e. the panel is self-contained).
|
||||
assert rendered.rstrip().endswith("╯")
|
||||
|
||||
# The description gets truncated — marker should appear.
|
||||
assert "(description truncated)" in rendered
|
||||
|
||||
def test_approval_display_skips_description_on_very_short_terminal(self):
|
||||
"""On a 12-row terminal, only the command and choices have room.
|
||||
|
||||
The description is dropped entirely rather than partially shown, so the
|
||||
choices never get clipped.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "rm -rf /var/log/apache2/*.log",
|
||||
"description": "recursive delete",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 12))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# Command visible.
|
||||
assert "rm -rf /var/log/apache2/*.log" in rendered
|
||||
# All four choices visible.
|
||||
for label in ("Allow once", "Allow for this session",
|
||||
"Add to permanent allowlist", "Deny"):
|
||||
assert label in rendered, f"choice {label!r} missing"
|
||||
|
||||
def test_approval_display_truncates_giant_command_in_view_mode(self):
|
||||
"""If the user hits /view on a massive command, choices still render.
|
||||
|
||||
The command gets truncated with a marker; the description gets dropped
|
||||
if there's no remaining row budget.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
# 50 lines of command when wrapped at ~64 chars.
|
||||
giant_cmd = "bash -c 'echo " + ("x" * 3000) + "'"
|
||||
cli._approval_state = {
|
||||
"command": giant_cmd,
|
||||
"description": "shell command via -c/-lc flag",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"show_full": True,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 24))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# All four choices visible even with a huge command.
|
||||
for label in ("Allow once", "Allow for this session",
|
||||
"Add to permanent allowlist", "Deny"):
|
||||
assert label in rendered, f"choice {label!r} missing"
|
||||
|
||||
# Command got truncated with a marker.
|
||||
assert "(command truncated" in rendered
|
||||
|
||||
71
tests/cli/test_cli_copy_command.py
Normal file
71
tests/cli/test_cli_copy_command.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for CLI /copy command."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli() -> HermesCLI:
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "sess-copy-test"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._app = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_copy_copies_latest_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
result = cli_obj.process_command("/copy")
|
||||
|
||||
assert result is True
|
||||
mock_copy.assert_called_once_with("latest")
|
||||
|
||||
|
||||
def test_copy_with_index_uses_requested_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "assistant", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy 1")
|
||||
|
||||
mock_copy.assert_called_once_with("one")
|
||||
|
||||
|
||||
def test_copy_strips_reasoning_blocks_before_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<REASONING_SCRATCHPAD>internal</REASONING_SCRATCHPAD>\nVisible answer",
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy")
|
||||
|
||||
mock_copy.assert_called_once_with("Visible answer")
|
||||
|
||||
|
||||
def test_copy_invalid_index_does_not_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print:
|
||||
cli_obj.process_command("/copy 99")
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)
|
||||
105
tests/cli/test_cli_external_editor.py
Normal file
105
tests/cli/test_cli_external_editor.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for CLI external-editor support."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text=""):
|
||||
self.calls = []
|
||||
self.text = text
|
||||
self.cursor_position = len(text)
|
||||
|
||||
def open_in_editor(self, validate_and_handle=False):
|
||||
self.calls.append(validate_and_handle)
|
||||
|
||||
|
||||
class _FakeApp:
|
||||
def __init__(self):
|
||||
self.current_buffer = _FakeBuffer()
|
||||
|
||||
|
||||
def _make_cli(with_app=True):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = _FakeApp() if with_app else None
|
||||
cli_obj._command_running = False
|
||||
cli_obj._command_status = ""
|
||||
cli_obj._command_display = ""
|
||||
cli_obj._sudo_state = None
|
||||
cli_obj._secret_state = None
|
||||
cli_obj._approval_state = None
|
||||
cli_obj._clarify_state = None
|
||||
cli_obj._skip_paste_collapse = False
|
||||
return cli_obj
|
||||
|
||||
def test_open_external_editor_uses_prompt_toolkit_buffer_editor():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._open_external_editor() is True
|
||||
assert cli_obj._app.current_buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_when_no_tui():
|
||||
cli_obj = _make_cli(with_app=False)
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "interactive cli" in str(mock_cprint.call_args).lower()
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_modal_prompts():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._approval_state = {"selected": 0}
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "active prompt" in str(mock_cprint.call_args).lower()
|
||||
|
||||
def test_open_external_editor_uses_explicit_buffer_when_provided():
|
||||
cli_obj = _make_cli()
|
||||
external_buffer = _FakeBuffer()
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=external_buffer) is True
|
||||
assert external_buffer.calls == [False]
|
||||
assert cli_obj._app.current_buffer.calls == []
|
||||
|
||||
|
||||
def test_expand_paste_references_replaces_placeholder_with_file_contents(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("line one\nline two", encoding="utf-8")
|
||||
|
||||
text = f"before [Pasted text #1: 2 lines → {paste_file}] after"
|
||||
expanded = cli_obj._expand_paste_references(text)
|
||||
|
||||
assert expanded == "before line one\nline two after"
|
||||
|
||||
|
||||
def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("alpha\nbeta", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines → {paste_file}]")
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
assert buffer.text == "alpha\nbeta"
|
||||
assert buffer.cursor_position == len("alpha\nbeta")
|
||||
assert buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("a\nb\nc\nd\ne\nf", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 6 lines \u2192 {paste_file}]")
|
||||
|
||||
# After expansion the flag should have been set (to prevent re-collapse)
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
# Flag is consumed by _on_text_changed, but since no handler is attached
|
||||
# in tests it stays True until the handler resets it.
|
||||
assert cli_obj._skip_paste_collapse is True
|
||||
117
tests/cli/test_cli_markdown_rendering.py
Normal file
117
tests/cli/test_cli_markdown_rendering.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from cli import _render_final_assistant_content
|
||||
|
||||
|
||||
def _render_to_text(renderable) -> str:
|
||||
buf = StringIO()
|
||||
Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_final_assistant_content_uses_markdown_renderable():
|
||||
renderable = _render_final_assistant_content("# Title\n\n- one\n- two")
|
||||
|
||||
assert isinstance(renderable, Markdown)
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "one" in output
|
||||
assert "two" in output
|
||||
|
||||
|
||||
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
|
||||
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "\x1b" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_strip_markdown_syntax():
|
||||
renderable = _render_final_assistant_content(
|
||||
"***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Bold italic" in output
|
||||
assert "Strike" in output
|
||||
assert "item" in output
|
||||
assert "Title" in output
|
||||
assert "code" in output
|
||||
assert "***" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- Ran prettier" in output
|
||||
assert "- Files changed" in output
|
||||
assert "- Verified clean" in output
|
||||
assert "**" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_ordered_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"1. First item\n2. Second item\n3. Third item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "1. First" in output
|
||||
assert "2. Second" in output
|
||||
assert "3. Third" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_blockquotes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"> This is quoted text\n> Another quoted line",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "> This is quoted" in output
|
||||
assert "> Another quoted" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_checkboxes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"- [ ] Todo item\n- [x] Done item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- [ ] Todo" in output
|
||||
assert "- [x] Done" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():
|
||||
renderable = _render_final_assistant_content(
|
||||
"| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "| Syntax | Example |" in output
|
||||
assert "|---|---|" in output
|
||||
assert "| Bold | bold |" in output
|
||||
assert "| Strike | strike |" in output
|
||||
assert "**" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_leave_markdown_raw():
|
||||
renderable = _render_final_assistant_content("***Bold italic***", mode="raw")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "***Bold italic***" in output
|
||||
@@ -34,6 +34,7 @@ class _FakeAgent:
|
||||
[{"id": "t1", "content": "unfinished task", "status": "in_progress"}]
|
||||
)
|
||||
self.flush_memories = MagicMock()
|
||||
self.commit_memory_session = MagicMock()
|
||||
self._invalidate_system_prompt = MagicMock()
|
||||
|
||||
# Token counters (non-zero to verify reset)
|
||||
|
||||
@@ -207,48 +207,11 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://openrouter.ai/api/v1"
|
||||
shell.api_key = "sk-primary"
|
||||
shell._smart_model_routing = {"enabled": False}
|
||||
|
||||
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
|
||||
|
||||
assert result["model"] == "gpt-5"
|
||||
assert result["runtime"]["provider"] == "openrouter"
|
||||
assert result["label"] is None
|
||||
|
||||
|
||||
def test_cli_turn_routing_uses_cheap_model_when_simple(monkeypatch):
|
||||
cli = _import_cli()
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
assert kwargs["requested"] == "zai"
|
||||
return {
|
||||
"provider": "zai",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://open.z.ai/api/v1",
|
||||
"api_key": "cheap-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
|
||||
shell = cli.HermesCLI(model="anthropic/claude-sonnet-4", compact=True, max_turns=1)
|
||||
shell.provider = "openrouter"
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://openrouter.ai/api/v1"
|
||||
shell.api_key = "primary-key"
|
||||
shell._smart_model_routing = {
|
||||
"enabled": True,
|
||||
"cheap_model": {"provider": "zai", "model": "glm-5-air"},
|
||||
"max_simple_chars": 160,
|
||||
"max_simple_words": 28,
|
||||
}
|
||||
|
||||
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
|
||||
|
||||
assert result["model"] == "glm-5-air"
|
||||
assert result["runtime"]["provider"] == "zai"
|
||||
assert result["runtime"]["api_key"] == "cheap-key"
|
||||
assert result["label"] is not None
|
||||
|
||||
|
||||
def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
|
||||
@@ -308,7 +271,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
|
||||
|
||||
|
||||
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "elevenlabs"},
|
||||
@@ -333,21 +296,17 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_
|
||||
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
|
||||
lambda: ["Nous subscription enables managed web tools."],
|
||||
)
|
||||
|
||||
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Nous subscription enables managed web tools." in out
|
||||
assert "Default model set to:" in out
|
||||
assert config["tts"]["provider"] == "elevenlabs"
|
||||
assert config["browser"]["cloud_provider"] == "browser-use"
|
||||
|
||||
|
||||
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "edge"},
|
||||
@@ -355,13 +314,13 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
lambda provider: {"access_token": "nous-token"},
|
||||
lambda provider: {"access_token": "***"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://inference.example.com/v1",
|
||||
"api_key": "nous-key",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -371,17 +330,12 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat
|
||||
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
|
||||
lambda: ["Nous subscription enables managed web tools."],
|
||||
)
|
||||
|
||||
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Nous subscription enables managed web tools." in out
|
||||
assert "OpenAI TTS via your Nous subscription" in out
|
||||
assert config["tts"]["provider"] == "openai"
|
||||
# Tool Gateway prompt should be shown (input() raises OSError in pytest
|
||||
# which is caught, so the prompt text appears but nothing is applied)
|
||||
assert "Tool Gateway" in out
|
||||
|
||||
|
||||
def test_codex_provider_uses_config_model(monkeypatch):
|
||||
@@ -578,7 +532,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
# After the probe detects a single model ("llm"), the flow asks
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||
# then display name.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", ""])
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", "", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
|
||||
|
||||
|
||||
@@ -64,6 +64,24 @@ class TestSaveConfigValueAtomic:
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["display"]["skin"] == "ares"
|
||||
|
||||
def test_preserves_env_ref_templates_in_unrelated_fields(self, config_env):
|
||||
"""The /model --global persistence path must not inline env-backed secrets."""
|
||||
config_env.write_text(yaml.dump({
|
||||
"custom_providers": [{
|
||||
"name": "tuzi",
|
||||
"api_key": "${TU_ZI_API_KEY}",
|
||||
"model": "claude-opus-4-6",
|
||||
}],
|
||||
"model": {"default": "test-model", "provider": "openrouter"},
|
||||
}))
|
||||
|
||||
from cli import save_config_value
|
||||
save_config_value("model.default", "doubao-pro")
|
||||
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["model"]["default"] == "doubao-pro"
|
||||
assert result["custom_providers"][0]["api_key"] == "${TU_ZI_API_KEY}"
|
||||
|
||||
def test_file_not_truncated_on_error(self, config_env, monkeypatch):
|
||||
"""If atomic_yaml_write raises, the original file is untouched."""
|
||||
original_content = config_env.read_text()
|
||||
|
||||
@@ -237,6 +237,13 @@ class TestCLIStatusBar:
|
||||
cli_obj._spinner_text = ""
|
||||
assert cli_obj._spinner_widget_height(width=90) == 0
|
||||
|
||||
def test_spinner_height_uses_display_width_for_wide_characters(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "你" * 40
|
||||
cli_obj._tool_start_time = 0
|
||||
|
||||
assert cli_obj._spinner_widget_height(width=64) == 2
|
||||
|
||||
def test_voice_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for CLI /status command behavior."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -83,3 +84,18 @@ def test_show_session_status_prints_gateway_style_summary():
|
||||
_, kwargs = cli_obj.console.print.call_args
|
||||
assert kwargs.get("highlight") is False
|
||||
assert kwargs.get("markup") is False
|
||||
|
||||
|
||||
def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path, capsys):
|
||||
"""Profile detection works for custom-root deployments (not under ~/.hermes)."""
|
||||
cli_obj = _make_cli()
|
||||
profile_home = tmp_path / "profiles" / "coder"
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "unrelated-home")
|
||||
|
||||
cli_obj._handle_profile_command()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Profile: coder" in out
|
||||
assert f"Home: {profile_home}" in out
|
||||
|
||||
92
tests/cli/test_cli_user_message_preview.py
Normal file
92
tests/cli/test_cli_user_message_preview.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
_cli_mod = None
|
||||
|
||||
|
||||
def _make_cli(user_message_preview=None):
|
||||
global _cli_mod
|
||||
clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {
|
||||
"compact": False,
|
||||
"tool_progress": "all",
|
||||
"user_message_preview": user_message_preview or {"first_lines": 2, "last_lines": 2},
|
||||
},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
prompt_toolkit_stubs = {
|
||||
"prompt_toolkit": MagicMock(),
|
||||
"prompt_toolkit.history": MagicMock(),
|
||||
"prompt_toolkit.styles": MagicMock(),
|
||||
"prompt_toolkit.patch_stdout": MagicMock(),
|
||||
"prompt_toolkit.application": MagicMock(),
|
||||
"prompt_toolkit.layout": MagicMock(),
|
||||
"prompt_toolkit.layout.processors": MagicMock(),
|
||||
"prompt_toolkit.filters": MagicMock(),
|
||||
"prompt_toolkit.layout.dimension": MagicMock(),
|
||||
"prompt_toolkit.layout.menus": MagicMock(),
|
||||
"prompt_toolkit.widgets": MagicMock(),
|
||||
"prompt_toolkit.key_binding": MagicMock(),
|
||||
"prompt_toolkit.completion": MagicMock(),
|
||||
"prompt_toolkit.formatted_text": MagicMock(),
|
||||
"prompt_toolkit.auto_suggest": MagicMock(),
|
||||
}
|
||||
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict("os.environ", clean_env, clear=False):
|
||||
import cli as mod
|
||||
|
||||
mod = importlib.reload(mod)
|
||||
_cli_mod = mod
|
||||
with patch.object(mod, "get_tool_definitions", return_value=[]), patch.dict(mod.__dict__, {"CLI_CONFIG": clean_config}):
|
||||
return mod.HermesCLI()
|
||||
|
||||
|
||||
class TestSubmittedUserMessagePreview:
|
||||
def test_default_preview_shows_first_two_lines_and_last_two_lines(self):
|
||||
cli = _make_cli()
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" in rendered
|
||||
assert "line6" in rendered
|
||||
assert "line3" not in rendered
|
||||
assert "line4" not in rendered
|
||||
assert "(+2 more lines)" in rendered
|
||||
|
||||
def test_preview_can_hide_last_lines(self):
|
||||
cli = _make_cli({"first_lines": 2, "last_lines": 0})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" not in rendered
|
||||
assert "line6" not in rendered
|
||||
assert "(+4 more lines)" in rendered
|
||||
|
||||
def test_invalid_first_lines_value_falls_back_to_one(self):
|
||||
cli = _make_cli({"first_lines": 0, "last_lines": 2})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview("line1\nline2\nline3\nline4")
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line3" in rendered
|
||||
assert "line4" in rendered
|
||||
assert "(+1 more line)" in rendered
|
||||
107
tests/cli/test_cwd_env_respect.py
Normal file
107
tests/cli/test_cwd_env_respect.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.
|
||||
|
||||
When the gateway resolves TERMINAL_CWD at startup and cli.py is later
|
||||
imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must
|
||||
not overwrite the already-resolved value with os.getcwd().
|
||||
|
||||
config.yaml terminal.cwd is the canonical source of truth.
|
||||
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
|
||||
See issue #10817.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
# The sentinel values that mean "resolve at runtime"
|
||||
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||
|
||||
|
||||
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
|
||||
"""Simulate the CWD resolution logic from load_cli_config().
|
||||
|
||||
This mirrors the code in cli.py that checks for a pre-resolved
|
||||
TERMINAL_CWD before falling back to os.getcwd().
|
||||
"""
|
||||
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||
_existing_cwd = env.get("TERMINAL_CWD", "")
|
||||
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
|
||||
terminal_config["cwd"] = _existing_cwd
|
||||
defaults["terminal"]["cwd"] = _existing_cwd
|
||||
else:
|
||||
effective_backend = terminal_config.get("env_type", "local")
|
||||
if effective_backend == "local":
|
||||
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
|
||||
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||
else:
|
||||
terminal_config.pop("cwd", None)
|
||||
|
||||
# Simulate the bridging loop: write terminal_config["cwd"] to env
|
||||
_file_has_terminal = defaults.get("_file_has_terminal", False)
|
||||
if "cwd" in terminal_config:
|
||||
if _file_has_terminal or "TERMINAL_CWD" not in env:
|
||||
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
|
||||
|
||||
return env.get("TERMINAL_CWD", "")
|
||||
|
||||
|
||||
class TestLazyImportGuard:
|
||||
"""TERMINAL_CWD resolved by gateway must survive a lazy cli.py import."""
|
||||
|
||||
def test_gateway_resolved_cwd_survives(self):
|
||||
"""Gateway set TERMINAL_CWD → lazy cli import must not clobber."""
|
||||
env = {"TERMINAL_CWD": "/home/user/workspace"}
|
||||
terminal_config = {"cwd": ".", "env_type": "local"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/home/user/workspace"
|
||||
|
||||
def test_gateway_resolved_cwd_survives_with_file_terminal(self):
|
||||
"""Even when config.yaml has a terminal: section, resolved CWD survives."""
|
||||
env = {"TERMINAL_CWD": "/home/user/workspace"}
|
||||
terminal_config = {"cwd": ".", "env_type": "local"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/home/user/workspace"
|
||||
|
||||
|
||||
class TestConfigCwdResolution:
|
||||
"""config.yaml terminal.cwd is the canonical source of truth."""
|
||||
|
||||
def test_explicit_config_cwd_wins(self):
|
||||
"""terminal.cwd: /explicit/path always wins."""
|
||||
env = {"TERMINAL_CWD": "/old/gateway/value"}
|
||||
terminal_config = {"cwd": "/explicit/path"}
|
||||
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/explicit/path"
|
||||
|
||||
def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
|
||||
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
|
||||
env = {}
|
||||
terminal_config = {"cwd": "."}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/fake/getcwd"
|
||||
|
||||
def test_remote_backend_pops_cwd(self):
|
||||
"""Remote backend + placeholder cwd → popped for backend default."""
|
||||
env = {}
|
||||
terminal_config = {"cwd": ".", "env_type": "docker"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "" # cwd popped, no env var set
|
||||
|
||||
def test_remote_backend_with_prior_cwd_preserves(self):
|
||||
"""Remote backend + pre-resolved TERMINAL_CWD → adopted."""
|
||||
env = {"TERMINAL_CWD": "/project"}
|
||||
terminal_config = {"cwd": ".", "env_type": "docker"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/project"
|
||||
@@ -183,27 +183,10 @@ class TestFastModeRouting(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
# Provider should NOT have changed
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
@@ -222,26 +205,10 @@ class TestFastModeRouting(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
primary_route = {
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
},
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route.get("request_overrides") is None
|
||||
@@ -329,27 +296,10 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"provider": "anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "claude-opus-4-6",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "anthropic"
|
||||
assert route["request_overrides"] == {"speed": "fast"}
|
||||
|
||||
21
tests/cli/test_gquota_command.py
Normal file
21
tests/cli/test_gquota_command.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_gquota_uses_chat_console_when_tui_is_live():
|
||||
from agent.google_oauth import GoogleOAuthError
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.console = MagicMock()
|
||||
cli._app = object()
|
||||
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console), \
|
||||
patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \
|
||||
patch("agent.google_oauth.load_credentials", return_value=None), \
|
||||
patch("agent.google_code_assist.retrieve_user_quota"):
|
||||
cli._handle_gquota_command("/gquota")
|
||||
|
||||
assert live_console.print.call_count == 2
|
||||
cli.console.print.assert_not_called()
|
||||
@@ -21,6 +21,7 @@ def test_manual_compress_reports_noop_without_success_banner(capsys):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id # no-op compression: no split
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages):
|
||||
@@ -48,6 +49,7 @@ def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id # no-op: no split
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
@@ -64,3 +66,64 @@ def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
assert "✅ Compressed: 4 → 3 messages" in output
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in output
|
||||
assert "denser summaries" in output
|
||||
|
||||
|
||||
def test_manual_compress_syncs_session_id_after_split():
|
||||
"""Regression for cli.session_id desync after /compress.
|
||||
|
||||
_compress_context ends the parent session and creates a new child session,
|
||||
mutating agent.session_id. Without syncing, cli.session_id still points
|
||||
at the ended parent — causing /status, /resume, exit summary, and the
|
||||
next end_session() call (e.g. from /resume <id>) to target the wrong row.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
old_id = shell.session_id
|
||||
new_child_id = "20260101_000000_child1"
|
||||
|
||||
compressed = [
|
||||
{"role": "user", "content": "[summary]"},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
# Simulate _compress_context mutating agent.session_id as a side effect.
|
||||
def _fake_compress(*args, **kwargs):
|
||||
shell.agent.session_id = new_child_id
|
||||
return (compressed, "")
|
||||
shell.agent._compress_context.side_effect = _fake_compress
|
||||
shell.agent.session_id = old_id # starts in sync
|
||||
shell._pending_title = "stale title"
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# CLI session_id must now point at the continuation child, not the parent.
|
||||
assert shell.session_id == new_child_id
|
||||
assert shell.session_id != old_id
|
||||
# Pending title must be cleared — titles belong to the parent lineage and
|
||||
# get regenerated for the continuation.
|
||||
assert shell._pending_title is None
|
||||
|
||||
|
||||
def test_manual_compress_no_sync_when_session_id_unchanged():
|
||||
"""If compression is a no-op (agent.session_id didn't change), the CLI
|
||||
must NOT clear _pending_title or otherwise disturb session state.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
shell._pending_title = "keep me"
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# No split → pending title untouched.
|
||||
assert shell._pending_title == "keep me"
|
||||
|
||||
@@ -144,6 +144,18 @@ class TestGatewayPersonalityNone:
|
||||
|
||||
assert "none" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_personality_list_uses_profile_display_path(self, tmp_path):
|
||||
runner = self._make_runner(personalities={})
|
||||
(tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}}))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path), \
|
||||
patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"):
|
||||
event = self._make_event("")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert result == "No personalities configured in `~/.hermes/profiles/coder/config.yaml`"
|
||||
|
||||
|
||||
class TestPersonalityDictFormat:
|
||||
"""Test dict-format custom personalities with description, tone, style."""
|
||||
|
||||
@@ -33,6 +33,20 @@ class TestCLIQuickCommands:
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
|
||||
def test_exec_command_uses_chat_console_when_tui_is_live(self):
|
||||
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
||||
cli._app = object()
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console):
|
||||
result = cli.process_command("/dn")
|
||||
|
||||
assert result is True
|
||||
live_console.print.assert_called_once()
|
||||
printed = self._printed_plain(live_console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
cli.console.print.assert_not_called()
|
||||
|
||||
def test_exec_command_stderr_shown_on_no_stdout(self):
|
||||
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
|
||||
result = cli.process_command("/err")
|
||||
|
||||
@@ -473,6 +473,7 @@ class TestInlineThinkBlockExtraction(unittest.TestCase):
|
||||
agent.verbose_logging = False
|
||||
agent.reasoning_callback = None
|
||||
agent.stream_delta_callback = None # non-streaming by default
|
||||
agent._stream_callback = None # non-streaming by default
|
||||
return agent
|
||||
|
||||
def test_single_think_block_extracted(self):
|
||||
@@ -619,6 +620,7 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.reasoning_callback = None
|
||||
agent.stream_delta_callback = None
|
||||
agent._stream_callback = None
|
||||
agent.verbose_logging = False
|
||||
return agent
|
||||
|
||||
|
||||
@@ -344,6 +344,127 @@ class TestDisplayResumedHistory:
|
||||
assert "Just thinking" not in output
|
||||
assert "Hi there!" in output
|
||||
|
||||
def test_think_tags_stripped(self):
|
||||
"""<think>...</think> blocks should be stripped from display (#11316)."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Solve this"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<think>\nI need to reason carefully here.\n</think>\n\nThe answer is 7.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<think>" not in output
|
||||
assert "</think>" not in output
|
||||
assert "I need to reason carefully here" not in output
|
||||
assert "The answer is 7" in output
|
||||
|
||||
def test_thinking_tags_stripped(self):
|
||||
"""<thinking>...</thinking> blocks should be stripped from display."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "What is 2+2?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<thinking>\nLet me compute: 2 + 2 = 4\n</thinking>\n\nThe answer is 4.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<thinking>" not in output
|
||||
assert "Let me compute" not in output
|
||||
assert "The answer is 4" in output
|
||||
|
||||
def test_reasoning_tags_stripped(self):
|
||||
"""<reasoning>...</reasoning> blocks should be stripped from display."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Explain gravity"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"<reasoning>\nGravity is a fundamental force...\n</reasoning>\n\n"
|
||||
"Gravity pulls objects together."
|
||||
),
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<reasoning>" not in output
|
||||
assert "fundamental force" not in output
|
||||
assert "Gravity pulls objects together" in output
|
||||
|
||||
def test_thought_tags_stripped(self):
|
||||
"""<thought>...</thought> blocks (Gemma 4) should be stripped."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Say hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<thought>\nInternal thought here.\n</thought>\n\nHello!",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<thought>" not in output
|
||||
assert "Internal thought here" not in output
|
||||
assert "Hello!" in output
|
||||
|
||||
def test_unclosed_think_tag_stripped(self):
|
||||
"""Unclosed <think> (truncated generation) should not leak reasoning."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Truncated response"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Some text before.\n<think>\nUnfinished reasoning...",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<think>" not in output
|
||||
assert "Unfinished reasoning" not in output
|
||||
assert "Some text before" in output
|
||||
|
||||
def test_multiple_reasoning_blocks_all_stripped(self):
|
||||
"""Multiple interleaved reasoning blocks are all stripped."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Complex question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"<think>\nFirst thought.\n</think>\n"
|
||||
"Partial text.\n"
|
||||
"<reasoning>\nSecond thought.\n</reasoning>\n"
|
||||
"Final answer."
|
||||
),
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "First thought" not in output
|
||||
assert "Second thought" not in output
|
||||
assert "Partial text" in output
|
||||
assert "Final answer" in output
|
||||
|
||||
def test_orphan_closing_think_tag_stripped(self):
|
||||
"""A stray </think> with no matching open should not render to user."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Broken output"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "some leftover reasoning</think>Visible answer.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "</think>" not in output
|
||||
assert "Visible answer" in output
|
||||
|
||||
def test_assistant_with_text_and_tool_calls(self):
|
||||
"""When an assistant message has both text content AND tool_calls."""
|
||||
cli = _make_cli()
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Surrogates (U+D800..U+DFFF) are invalid in UTF-8 and crash json.dumps()
|
||||
inside the OpenAI SDK. They can appear via clipboard paste from rich-text
|
||||
editors like Google Docs.
|
||||
editors like Google Docs, OR from byte-level reasoning models (xiaomi/mimo,
|
||||
kimi, glm) emitting lone halves in reasoning output.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
@@ -11,6 +12,7 @@ from unittest.mock import MagicMock, patch
|
||||
from run_agent import (
|
||||
_sanitize_surrogates,
|
||||
_sanitize_messages_surrogates,
|
||||
_sanitize_structure_surrogates,
|
||||
_SURROGATE_RE,
|
||||
)
|
||||
|
||||
@@ -109,6 +111,186 @@ class TestSanitizeMessagesSurrogates:
|
||||
assert "\ufffd" in msgs[0]["content"]
|
||||
|
||||
|
||||
class TestReasoningFieldSurrogates:
|
||||
"""Surrogates in reasoning fields (byte-level reasoning models).
|
||||
|
||||
xiaomi/mimo, kimi, glm and similar byte-level tokenizers can emit lone
|
||||
surrogates in reasoning output. These fields are carried through to the
|
||||
API as `reasoning_content` on assistant messages, and must be sanitized
|
||||
or json.dumps() crashes with 'utf-8' codec can't encode surrogates.
|
||||
"""
|
||||
|
||||
def test_reasoning_field_sanitized(self):
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "ok", "reasoning": "thought \udce2 here"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning"]
|
||||
assert "\ufffd" in msgs[0]["reasoning"]
|
||||
|
||||
def test_reasoning_content_field_sanitized(self):
|
||||
"""api_messages carry `reasoning_content` built from `reasoning`."""
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "ok", "reasoning_content": "thought \udce2 here"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning_content"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_content"]
|
||||
|
||||
def test_reasoning_details_nested_sanitized(self):
|
||||
"""reasoning_details is a list of dicts with nested string fields."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning_details": [
|
||||
{"type": "reasoning.summary", "summary": "summary \udce2 text"},
|
||||
{"type": "reasoning.text", "text": "chain \udc00 of thought"},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning_details"][0]["summary"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_details"][0]["summary"]
|
||||
assert "\udc00" not in msgs[0]["reasoning_details"][1]["text"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_details"][1]["text"]
|
||||
|
||||
def test_deeply_nested_reasoning_sanitized(self):
|
||||
"""Nested dicts / lists inside extra fields are recursed into."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning_details": [
|
||||
{
|
||||
"type": "reasoning.encrypted",
|
||||
"content": {
|
||||
"encrypted_content": "opaque",
|
||||
"text_parts": ["part1", "part2 \udce2 part"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert (
|
||||
msgs[0]["reasoning_details"][0]["content"]["text_parts"][1]
|
||||
== "part2 \ufffd part"
|
||||
)
|
||||
|
||||
def test_reasoning_end_to_end_json_serialization(self):
|
||||
"""After sanitization, the full message dict must serialize clean."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "answer",
|
||||
"reasoning_content": "reasoning with \udce2 surrogate",
|
||||
"reasoning_details": [
|
||||
{"summary": "nested \udcb0 surrogate"},
|
||||
],
|
||||
},
|
||||
]
|
||||
_sanitize_messages_surrogates(msgs)
|
||||
# Must round-trip through json + utf-8 encoding without error
|
||||
payload = json.dumps(msgs, ensure_ascii=False).encode("utf-8")
|
||||
assert b"\\" not in payload[:0] # sanity — just ensure we got bytes
|
||||
assert len(payload) > 0
|
||||
|
||||
def test_no_surrogates_returns_false(self):
|
||||
"""Clean reasoning fields don't trigger a modification."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning": "clean thought",
|
||||
"reasoning_content": "also clean",
|
||||
"reasoning_details": [{"summary": "clean summary"}],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is False
|
||||
|
||||
|
||||
class TestSanitizeStructureSurrogates:
|
||||
"""Test the _sanitize_structure_surrogates() helper for nested payloads."""
|
||||
|
||||
def test_empty_payload(self):
|
||||
assert _sanitize_structure_surrogates({}) is False
|
||||
assert _sanitize_structure_surrogates([]) is False
|
||||
|
||||
def test_flat_dict(self):
|
||||
payload = {"a": "clean", "b": "dirty \udce2 text"}
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert payload["a"] == "clean"
|
||||
assert "\ufffd" in payload["b"]
|
||||
|
||||
def test_flat_list(self):
|
||||
payload = ["clean", "dirty \udce2"]
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert payload[0] == "clean"
|
||||
assert "\ufffd" in payload[1]
|
||||
|
||||
def test_nested_dict_in_list(self):
|
||||
payload = [{"x": "dirty \udce2"}, {"x": "clean"}]
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert "\ufffd" in payload[0]["x"]
|
||||
assert payload[1]["x"] == "clean"
|
||||
|
||||
def test_deeply_nested(self):
|
||||
payload = {
|
||||
"level1": {
|
||||
"level2": [
|
||||
{"level3": "deep \udce2 surrogate"},
|
||||
],
|
||||
},
|
||||
}
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert "\ufffd" in payload["level1"]["level2"][0]["level3"]
|
||||
|
||||
def test_clean_payload_returns_false(self):
|
||||
payload = {"a": "clean", "b": [{"c": "also clean"}]}
|
||||
assert _sanitize_structure_surrogates(payload) is False
|
||||
|
||||
def test_non_string_values_ignored(self):
|
||||
payload = {"int": 42, "list": [1, 2, 3], "dict": {"none": None}, "bool": True}
|
||||
assert _sanitize_structure_surrogates(payload) is False
|
||||
# Non-string values survive unchanged
|
||||
assert payload["int"] == 42
|
||||
assert payload["list"] == [1, 2, 3]
|
||||
|
||||
|
||||
class TestApiMessagesSurrogateRecovery:
|
||||
"""Integration: verify the recovery block sanitizes api_messages.
|
||||
|
||||
The bug this guards against: a surrogate in `reasoning_content` on
|
||||
api_messages (transformed from `reasoning` during build) crashes the
|
||||
OpenAI SDK's json.dumps(), and the recovery block previously only
|
||||
sanitized the canonical `messages` list — not `api_messages` — so the
|
||||
next retry would send the same broken payload and fail 3 times.
|
||||
"""
|
||||
|
||||
def test_api_messages_reasoning_content_sanitized(self):
|
||||
"""The extended sanitizer catches reasoning_content in api_messages."""
|
||||
api_messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "response",
|
||||
"reasoning_content": "thought \udce2 trail",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {"name": "tool", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "result", "tool_call_id": "call_1"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(api_messages) is True
|
||||
assert "\udce2" not in api_messages[1]["reasoning_content"]
|
||||
# Full payload must now serialize clean
|
||||
json.dumps(api_messages, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
class TestRunConversationSurrogateSanitization:
|
||||
"""Integration: verify run_conversation sanitizes user_message."""
|
||||
|
||||
@@ -138,7 +320,7 @@ class TestRunConversationSurrogateSanitization:
|
||||
mock_stream.return_value = mock_response
|
||||
mock_api.return_value = mock_response
|
||||
|
||||
agent = AIAgent(model="test/model", quiet_mode=True, skip_memory=True, skip_context_files=True)
|
||||
agent = AIAgent(model="test/model", api_key="test-key", base_url="http://localhost:1234/v1", quiet_mode=True, skip_memory=True, skip_context_files=True)
|
||||
agent.client = MagicMock()
|
||||
|
||||
# Pass a message with surrogates
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
"""Shared fixtures for the hermes-agent test suite."""
|
||||
"""Shared fixtures for the hermes-agent test suite.
|
||||
|
||||
Hermetic-test invariants enforced here (see AGENTS.md for rationale):
|
||||
|
||||
1. **No credential env vars.** All provider/credential-shaped env vars
|
||||
(ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD, _CREDENTIALS, etc.)
|
||||
are unset before every test. Local developer keys cannot leak in.
|
||||
2. **Isolated HERMES_HOME.** HERMES_HOME points to a per-test tempdir so
|
||||
code reading ``~/.hermes/*`` via ``get_hermes_home()`` can't see the
|
||||
real one. (We do NOT also redirect HOME — that broke subprocesses in
|
||||
CI. Code using ``Path.home() / ".hermes"`` instead of the canonical
|
||||
``get_hermes_home()`` is a bug to fix at the callsite.)
|
||||
3. **Deterministic runtime.** TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0.
|
||||
4. **No HERMES_SESSION_* inheritance** — the agent's current gateway
|
||||
session must not leak into tests.
|
||||
|
||||
These invariants make the local test run match CI closely. Gaps that
|
||||
remain (CPU count, xdist worker count) are addressed by the canonical
|
||||
test runner at ``scripts/run_tests.sh``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -16,30 +36,226 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
# ── Credential env-var filter ──────────────────────────────────────────────
|
||||
#
|
||||
# Any env var in the current process matching ONE of these patterns is
|
||||
# unset for every test. Developers' local keys cannot leak into assertions
|
||||
# about "auto-detect provider when key present".
|
||||
|
||||
_CREDENTIAL_SUFFIXES = (
|
||||
"_API_KEY",
|
||||
"_TOKEN",
|
||||
"_SECRET",
|
||||
"_PASSWORD",
|
||||
"_CREDENTIALS",
|
||||
"_ACCESS_KEY",
|
||||
"_SECRET_ACCESS_KEY",
|
||||
"_PRIVATE_KEY",
|
||||
"_OAUTH_TOKEN",
|
||||
"_WEBHOOK_SECRET",
|
||||
"_ENCRYPT_KEY",
|
||||
"_APP_SECRET",
|
||||
"_CLIENT_SECRET",
|
||||
"_CORP_SECRET",
|
||||
"_AES_KEY",
|
||||
)
|
||||
|
||||
# Explicit names (for ones that don't fit the suffix pattern)
|
||||
_CREDENTIAL_NAMES = frozenset({
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"FAL_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"NOUS_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"MISTRAL_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"MOONSHOT_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"OLLAMA_API_KEY",
|
||||
"OPENVIKING_API_KEY",
|
||||
"COPILOT_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"BROWSERBASE_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"EXA_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"WANDB_API_KEY",
|
||||
"ELEVENLABS_API_KEY",
|
||||
"HONCHO_API_KEY",
|
||||
"MEM0_API_KEY",
|
||||
"SUPERMEMORY_API_KEY",
|
||||
"RETAINDB_API_KEY",
|
||||
"HINDSIGHT_API_KEY",
|
||||
"HINDSIGHT_LLM_API_KEY",
|
||||
"TINKER_API_KEY",
|
||||
"DAYTONA_API_KEY",
|
||||
"TWILIO_AUTH_TOKEN",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"SLACK_BOT_TOKEN",
|
||||
"SLACK_APP_TOKEN",
|
||||
"MATTERMOST_TOKEN",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_RECOVERY_KEY",
|
||||
"HASS_TOKEN",
|
||||
"EMAIL_PASSWORD",
|
||||
"BLUEBUBBLES_PASSWORD",
|
||||
"FEISHU_APP_SECRET",
|
||||
"FEISHU_ENCRYPT_KEY",
|
||||
"FEISHU_VERIFICATION_TOKEN",
|
||||
"DINGTALK_CLIENT_SECRET",
|
||||
"QQ_CLIENT_SECRET",
|
||||
"QQ_STT_API_KEY",
|
||||
"WECOM_SECRET",
|
||||
"WECOM_CALLBACK_CORP_SECRET",
|
||||
"WECOM_CALLBACK_TOKEN",
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
"WEIXIN_TOKEN",
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"TERMINAL_SSH_KEY",
|
||||
"SUDO_PASSWORD",
|
||||
"GATEWAY_PROXY_KEY",
|
||||
"API_SERVER_KEY",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"TELEGRAM_WEBHOOK_SECRET",
|
||||
"WEBHOOK_SECRET",
|
||||
"AI_GATEWAY_API_KEY",
|
||||
"VOICE_TOOLS_OPENAI_KEY",
|
||||
"BROWSER_USE_API_KEY",
|
||||
"CUSTOM_API_KEY",
|
||||
"GATEWAY_PROXY_URL",
|
||||
"GEMINI_BASE_URL",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"GROQ_BASE_URL",
|
||||
"XAI_BASE_URL",
|
||||
"AI_GATEWAY_BASE_URL",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
})
|
||||
|
||||
|
||||
def _looks_like_credential(name: str) -> bool:
|
||||
"""True if env var name matches a credential-shaped pattern."""
|
||||
if name in _CREDENTIAL_NAMES:
|
||||
return True
|
||||
return any(name.endswith(suf) for suf in _CREDENTIAL_SUFFIXES)
|
||||
|
||||
|
||||
# HERMES_* vars that change test behavior by being set. Unset all of these
|
||||
# unconditionally — individual tests that need them set do so explicitly.
|
||||
_HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_YOLO_MODE",
|
||||
"HERMES_INTERACTIVE",
|
||||
"HERMES_QUIET",
|
||||
"HERMES_TOOL_PROGRESS",
|
||||
"HERMES_TOOL_PROGRESS_MODE",
|
||||
"HERMES_MAX_ITERATIONS",
|
||||
"HERMES_SESSION_PLATFORM",
|
||||
"HERMES_SESSION_CHAT_ID",
|
||||
"HERMES_SESSION_CHAT_NAME",
|
||||
"HERMES_SESSION_THREAD_ID",
|
||||
"HERMES_SESSION_SOURCE",
|
||||
"HERMES_SESSION_KEY",
|
||||
"HERMES_GATEWAY_SESSION",
|
||||
"HERMES_PLATFORM",
|
||||
"HERMES_INFERENCE_PROVIDER",
|
||||
"HERMES_MANAGED",
|
||||
"HERMES_DEV",
|
||||
"HERMES_CONTAINER",
|
||||
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
||||
"HERMES_TIMEZONE",
|
||||
"HERMES_REDACT_SECRETS",
|
||||
"HERMES_BACKGROUND_NOTIFICATIONS",
|
||||
"HERMES_EXEC_ASK",
|
||||
"HERMES_HOME_MODE",
|
||||
"BROWSER_CDP_URL",
|
||||
"CAMOFOX_URL",
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_hermes_home(tmp_path, monkeypatch):
|
||||
"""Redirect HERMES_HOME to a temp dir so tests never write to ~/.hermes/."""
|
||||
fake_home = tmp_path / "hermes_test"
|
||||
fake_home.mkdir()
|
||||
(fake_home / "sessions").mkdir()
|
||||
(fake_home / "cron").mkdir()
|
||||
(fake_home / "memories").mkdir()
|
||||
(fake_home / "skills").mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(fake_home))
|
||||
# Reset plugin singleton so tests don't leak plugins from ~/.hermes/plugins/
|
||||
def _hermetic_environment(tmp_path, monkeypatch):
|
||||
"""Blank out all credential/behavioral env vars so local and CI match.
|
||||
|
||||
Also redirects HOME and HERMES_HOME to per-test tempdirs so code that
|
||||
reads ``~/.hermes/*`` can't touch the real one, and pins TZ/LANG so
|
||||
datetime/locale-sensitive tests are deterministic.
|
||||
"""
|
||||
# 1. Blank every credential-shaped env var that's currently set.
|
||||
for name in list(os.environ.keys()):
|
||||
if _looks_like_credential(name):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
# 2. Blank behavioral HERMES_* vars that could change test semantics.
|
||||
for name in _HERMES_BEHAVIORAL_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
# 3. Redirect HERMES_HOME to a per-test tempdir. Code that reads
|
||||
# ``~/.hermes/*`` via ``get_hermes_home()`` now gets the tempdir.
|
||||
#
|
||||
# NOTE: We do NOT also redirect HOME. Doing so broke CI because
|
||||
# some tests (and their transitive deps) spawn subprocesses that
|
||||
# inherit HOME and expect it to be stable. If a test genuinely
|
||||
# needs HOME isolated, it should set it explicitly in its own
|
||||
# fixture. Any code in the codebase reading ``~/.hermes/*`` via
|
||||
# ``Path.home() / ".hermes"`` instead of ``get_hermes_home()``
|
||||
# is a bug to fix at the callsite.
|
||||
fake_hermes_home = tmp_path / "hermes_test"
|
||||
fake_hermes_home.mkdir()
|
||||
(fake_hermes_home / "sessions").mkdir()
|
||||
(fake_hermes_home / "cron").mkdir()
|
||||
(fake_hermes_home / "memories").mkdir()
|
||||
(fake_hermes_home / "skills").mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(fake_hermes_home))
|
||||
|
||||
# 4. Deterministic locale / timezone / hashseed. CI runs in UTC with
|
||||
# C.UTF-8 locale; local dev often doesn't. Pin everything.
|
||||
monkeypatch.setenv("TZ", "UTC")
|
||||
monkeypatch.setenv("LANG", "C.UTF-8")
|
||||
monkeypatch.setenv("LC_ALL", "C.UTF-8")
|
||||
monkeypatch.setenv("PYTHONHASHSEED", "0")
|
||||
|
||||
# 4b. Disable AWS IMDS lookups. Without this, any test that ends up
|
||||
# calling has_aws_credentials() / resolve_aws_auth_env_var()
|
||||
# (e.g. provider auto-detect, status command, cron run_job) burns
|
||||
# ~2s waiting for the metadata service at 169.254.169.254 to time
|
||||
# out. Tests don't run on EC2 — IMDS is always unreachable here.
|
||||
monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
|
||||
monkeypatch.setenv("AWS_METADATA_SERVICE_TIMEOUT", "1")
|
||||
monkeypatch.setenv("AWS_METADATA_SERVICE_NUM_ATTEMPTS", "1")
|
||||
|
||||
# 5. Reset plugin singleton so tests don't leak plugins from
|
||||
# ~/.hermes/plugins/ (which, per step 3, is now empty — but the
|
||||
# singleton might still be cached from a previous test).
|
||||
try:
|
||||
import hermes_cli.plugins as _plugins_mod
|
||||
monkeypatch.setattr(_plugins_mod, "_plugin_manager", None)
|
||||
except Exception:
|
||||
pass
|
||||
# Tests should not inherit the agent's current gateway/messaging surface.
|
||||
# Individual tests that need gateway behavior set these explicitly.
|
||||
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
# Avoid making real calls during tests if this key is set in the env files
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
|
||||
# Backward-compat alias — old tests reference this fixture name. Keep it
|
||||
# as a no-op wrapper so imports don't break.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_hermes_home(_hermetic_environment):
|
||||
"""Alias preserved for any test that yields this name explicitly."""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -152,7 +152,6 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch):
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner._smart_model_routing = {}
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
|
||||
@@ -8,6 +8,8 @@ from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, _send_media_via_adapter, run_job, SILENT_MARKER, _build_job_prompt
|
||||
from tools.env_passthrough import clear_env_passthrough
|
||||
from tools.credential_files import clear_credential_files
|
||||
|
||||
|
||||
class TestResolveOrigin:
|
||||
@@ -62,6 +64,60 @@ class TestResolveDeliveryTarget:
|
||||
"thread_id": "17585",
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "env_var", "chat_id"),
|
||||
[
|
||||
("matrix", "MATRIX_HOME_ROOM", "!bot-room:example.org"),
|
||||
("signal", "SIGNAL_HOME_CHANNEL", "+15551234567"),
|
||||
("mattermost", "MATTERMOST_HOME_CHANNEL", "team-town-square"),
|
||||
("sms", "SMS_HOME_CHANNEL", "+15557654321"),
|
||||
("email", "EMAIL_HOME_ADDRESS", "home@example.com"),
|
||||
("dingtalk", "DINGTALK_HOME_CHANNEL", "cidNNN"),
|
||||
("feishu", "FEISHU_HOME_CHANNEL", "oc_home"),
|
||||
("wecom", "WECOM_HOME_CHANNEL", "wecom-home"),
|
||||
("weixin", "WEIXIN_HOME_CHANNEL", "wxid_home"),
|
||||
("qqbot", "QQ_HOME_CHANNEL", "group-openid-home"),
|
||||
],
|
||||
)
|
||||
def test_origin_delivery_without_origin_falls_back_to_supported_home_channels(
|
||||
self, monkeypatch, platform, env_var, chat_id
|
||||
):
|
||||
for fallback_env in (
|
||||
"MATRIX_HOME_ROOM",
|
||||
"MATRIX_HOME_CHANNEL",
|
||||
"TELEGRAM_HOME_CHANNEL",
|
||||
"DISCORD_HOME_CHANNEL",
|
||||
"SLACK_HOME_CHANNEL",
|
||||
"SIGNAL_HOME_CHANNEL",
|
||||
"MATTERMOST_HOME_CHANNEL",
|
||||
"SMS_HOME_CHANNEL",
|
||||
"EMAIL_HOME_ADDRESS",
|
||||
"DINGTALK_HOME_CHANNEL",
|
||||
"BLUEBUBBLES_HOME_CHANNEL",
|
||||
"FEISHU_HOME_CHANNEL",
|
||||
"WECOM_HOME_CHANNEL",
|
||||
"WEIXIN_HOME_CHANNEL",
|
||||
"QQ_HOME_CHANNEL",
|
||||
):
|
||||
monkeypatch.delenv(fallback_env, raising=False)
|
||||
monkeypatch.setenv(env_var, chat_id)
|
||||
|
||||
assert _resolve_delivery_target({"deliver": "origin"}) == {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_bare_matrix_delivery_uses_matrix_home_room(self, monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_HOME_CHANNEL", raising=False)
|
||||
monkeypatch.setenv("MATRIX_HOME_ROOM", "!room123:example.org")
|
||||
|
||||
assert _resolve_delivery_target({"deliver": "matrix"}) == {
|
||||
"platform": "matrix",
|
||||
"chat_id": "!room123:example.org",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_telegram_topic_target_with_thread_id(self):
|
||||
"""deliver: 'telegram:chat_id:thread_id' parses correctly."""
|
||||
job = {
|
||||
@@ -233,9 +289,10 @@ class TestDeliverResultWrapping:
|
||||
send_mock.assert_called_once()
|
||||
sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1]
|
||||
assert "Cronjob Response: daily-report" in sent_content
|
||||
assert "(job_id: test-job)" in sent_content
|
||||
assert "-------------" in sent_content
|
||||
assert "Here is today's summary." in sent_content
|
||||
assert "The agent cannot see this message" in sent_content
|
||||
assert "To stop or manage this job" in sent_content
|
||||
|
||||
def test_delivery_uses_job_id_when_no_name(self):
|
||||
"""When a job has no name, the wrapper should fall back to job id."""
|
||||
@@ -545,41 +602,6 @@ class TestDeliverResultWrapping:
|
||||
class TestDeliverResultErrorReturns:
|
||||
"""Verify _deliver_result returns error strings on failure, None on success."""
|
||||
|
||||
def test_returns_none_on_successful_delivery(self):
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})):
|
||||
job = {
|
||||
"id": "ok-job",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
result = _deliver_result(job, "Output.")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_local_delivery(self):
|
||||
"""local-only jobs don't deliver — not a failure."""
|
||||
job = {"id": "local-job", "deliver": "local"}
|
||||
result = _deliver_result(job, "Output.")
|
||||
assert result is None
|
||||
|
||||
def test_returns_error_for_unknown_platform(self):
|
||||
job = {
|
||||
"id": "bad-platform",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "fax", "chat_id": "123"},
|
||||
}
|
||||
with patch("gateway.config.load_gateway_config"):
|
||||
result = _deliver_result(job, "Output.")
|
||||
assert result is not None
|
||||
assert "unknown platform" in result
|
||||
|
||||
def test_returns_error_when_platform_disabled(self):
|
||||
from gateway.config import Platform
|
||||
|
||||
@@ -598,25 +620,6 @@ class TestDeliverResultErrorReturns:
|
||||
assert result is not None
|
||||
assert "not configured" in result
|
||||
|
||||
def test_returns_error_on_send_failure(self):
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"error": "rate limited"})):
|
||||
job = {
|
||||
"id": "rate-limited",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
result = _deliver_result(job, "Output.")
|
||||
assert result is not None
|
||||
assert "rate limited" in result
|
||||
|
||||
def test_returns_error_for_unresolved_target(self, monkeypatch):
|
||||
"""Non-local delivery with no resolvable target should return an error."""
|
||||
monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False)
|
||||
@@ -672,7 +675,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
|
||||
"""Empty final_response should stay empty for delivery logic (issue #2234).
|
||||
|
||||
|
||||
The placeholder '(No response generated)' should only appear in the
|
||||
output log, not in the returned final_response that's used for delivery.
|
||||
"""
|
||||
@@ -690,7 +693,7 @@ class TestRunJobSessionPersistence:
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
@@ -711,6 +714,43 @@ class TestRunJobSessionPersistence:
|
||||
# But the output log should show the placeholder
|
||||
assert "(No response generated)" in output
|
||||
|
||||
def test_tick_marks_empty_response_as_error(self, tmp_path):
|
||||
"""When run_job returns success=True but final_response is empty,
|
||||
tick() should mark the job as error so last_status != 'ok'.
|
||||
(issue #8585)
|
||||
"""
|
||||
from cron.scheduler import tick
|
||||
from cron.jobs import load_jobs, save_jobs
|
||||
|
||||
job = {
|
||||
"id": "empty-job",
|
||||
"name": "empty-test",
|
||||
"prompt": "do something",
|
||||
"schedule": "every 1h",
|
||||
"enabled": True,
|
||||
"next_run_at": "2020-01-01T00:00:00",
|
||||
"deliver": "local",
|
||||
"last_status": None,
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler.get_due_jobs", return_value=[job]), \
|
||||
patch("cron.scheduler.advance_next_run"), \
|
||||
patch("cron.scheduler.mark_job_run") as mock_mark, \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "output", "", None)):
|
||||
tick(verbose=False)
|
||||
|
||||
# Should be called with success=False because final_response is empty
|
||||
mock_mark.assert_called_once()
|
||||
call_args = mock_mark.call_args
|
||||
assert call_args[0][0] == "empty-job"
|
||||
assert call_args[0][1] is False # success should be False
|
||||
assert "empty" in call_args[0][2].lower() # error should mention empty
|
||||
|
||||
def test_run_job_sets_auto_delivery_env_from_dotenv_home_channel(self, tmp_path, monkeypatch):
|
||||
job = {
|
||||
"id": "test-job",
|
||||
@@ -824,58 +864,118 @@ class TestRunJobConfigLogging:
|
||||
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
|
||||
class TestRunJobPerJobOverrides:
|
||||
def test_job_level_model_provider_and_base_url_overrides_are_used(self, tmp_path):
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(
|
||||
"model:\n"
|
||||
" default: gpt-5.4\n"
|
||||
" provider: openai-codex\n"
|
||||
" base_url: https://chatgpt.com/backend-api/codex\n"
|
||||
)
|
||||
|
||||
class TestRunJobSkillBacked:
|
||||
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
|
||||
job = {
|
||||
"id": "briefing-job",
|
||||
"name": "briefing",
|
||||
"prompt": "hello",
|
||||
"model": "perplexity/sonar-pro",
|
||||
"provider": "custom",
|
||||
"base_url": "http://127.0.0.1:4000/v1",
|
||||
"id": "skill-env-job",
|
||||
"name": "skill env test",
|
||||
"prompt": "Use the skill.",
|
||||
"skill": "notion",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "http://127.0.0.1:4000/v1",
|
||||
"api_key": "***",
|
||||
}
|
||||
|
||||
def _skill_view(name):
|
||||
assert name == "notion"
|
||||
from tools.env_passthrough import register_env_passthrough
|
||||
|
||||
register_env_passthrough(["NOTION_API_KEY"])
|
||||
return json.dumps({"success": True, "content": "# notion\nUse Notion."})
|
||||
|
||||
def _run_conversation(prompt):
|
||||
from tools.env_passthrough import get_all_passthrough
|
||||
|
||||
assert "NOTION_API_KEY" in get_all_passthrough()
|
||||
return {"final_response": "ok"}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as runtime_mock, \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent.run_conversation.side_effect = _run_conversation
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
try:
|
||||
success, output, final_response, error = run_job(job)
|
||||
finally:
|
||||
clear_env_passthrough()
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert "ok" in output
|
||||
runtime_mock.assert_called_once_with(
|
||||
requested="custom",
|
||||
explicit_base_url="http://127.0.0.1:4000/v1",
|
||||
)
|
||||
assert mock_agent_cls.call_args.kwargs["model"] == "perplexity/sonar-pro"
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
def test_run_job_preserves_credential_file_passthrough_into_worker_thread(self, tmp_path):
|
||||
"""copy_context() also propagates credential_files ContextVar."""
|
||||
job = {
|
||||
"id": "cred-env-job",
|
||||
"name": "cred file test",
|
||||
"prompt": "Use the skill.",
|
||||
"skill": "google-workspace",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
# Create a credential file so register_credential_file succeeds
|
||||
cred_dir = tmp_path / "credentials"
|
||||
cred_dir.mkdir()
|
||||
(cred_dir / "google_token.json").write_text('{"token": "t"}')
|
||||
|
||||
def _skill_view(name):
|
||||
assert name == "google-workspace"
|
||||
from tools.credential_files import register_credential_file
|
||||
|
||||
register_credential_file("credentials/google_token.json")
|
||||
return json.dumps({"success": True, "content": "# google-workspace\nUse Google."})
|
||||
|
||||
def _run_conversation(prompt):
|
||||
from tools.credential_files import _get_registered
|
||||
|
||||
registered = _get_registered()
|
||||
assert registered, "credential files must be visible in worker thread"
|
||||
assert any("google_token.json" in v for v in registered.values())
|
||||
return {"final_response": "ok"}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("tools.credential_files._resolve_hermes_home", return_value=tmp_path), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.side_effect = _run_conversation
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
try:
|
||||
success, output, final_response, error = run_job(job)
|
||||
finally:
|
||||
clear_credential_files()
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
|
||||
class TestRunJobSkillBacked:
|
||||
def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path):
|
||||
job = {
|
||||
"id": "skill-job",
|
||||
@@ -924,7 +1024,7 @@ class TestRunJobSkillBacked:
|
||||
"id": "multi-skill-job",
|
||||
"name": "multi skill test",
|
||||
"prompt": "Combine the results.",
|
||||
"skills": ["blogwatcher", "find-nearby"],
|
||||
"skills": ["blogwatcher", "maps"],
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
@@ -957,12 +1057,12 @@ class TestRunJobSkillBacked:
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert skill_view_mock.call_count == 2
|
||||
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"]
|
||||
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "maps"]
|
||||
|
||||
prompt_arg = mock_agent.run_conversation.call_args.args[0]
|
||||
assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby")
|
||||
assert prompt_arg.index("blogwatcher") < prompt_arg.index("maps")
|
||||
assert "Instructions for blogwatcher." in prompt_arg
|
||||
assert "Instructions for find-nearby." in prompt_arg
|
||||
assert "Instructions for maps." in prompt_arg
|
||||
assert "Combine the results." in prompt_arg
|
||||
|
||||
|
||||
@@ -977,16 +1077,6 @@ class TestSilentDelivery:
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
|
||||
def test_normal_response_delivers(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_called_once()
|
||||
|
||||
def test_silent_response_suppresses_delivery(self, caplog):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
|
||||
@@ -1085,6 +1175,204 @@ class TestBuildJobPromptSilentHint:
|
||||
assert system_pos < prompt_pos
|
||||
|
||||
|
||||
class TestParseWakeGate:
|
||||
"""Unit tests for _parse_wake_gate — pure function, no side effects."""
|
||||
|
||||
def test_empty_output_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("") is True
|
||||
assert _parse_wake_gate(None) is True
|
||||
|
||||
def test_whitespace_only_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate(" \n\n \t\n") is True
|
||||
|
||||
def test_non_json_last_line_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("hello world") is True
|
||||
assert _parse_wake_gate("line 1\nline 2\nplain text") is True
|
||||
|
||||
def test_json_non_dict_wakes(self):
|
||||
"""Bare arrays, numbers, strings must not be interpreted as a gate."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("[1, 2, 3]") is True
|
||||
assert _parse_wake_gate("42") is True
|
||||
assert _parse_wake_gate('"wakeAgent"') is True
|
||||
|
||||
def test_wake_gate_false_skips(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": false}') is False
|
||||
|
||||
def test_wake_gate_true_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": true}') is True
|
||||
|
||||
def test_wake_gate_missing_wakes(self):
|
||||
"""A JSON dict without a wakeAgent key defaults to waking."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"data": {"foo": "bar"}}') is True
|
||||
|
||||
def test_non_boolean_false_still_wakes(self):
|
||||
"""Only strict ``False`` skips — truthy/falsy shortcuts are too risky."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": 0}') is True
|
||||
assert _parse_wake_gate('{"wakeAgent": null}') is True
|
||||
assert _parse_wake_gate('{"wakeAgent": ""}') is True
|
||||
|
||||
def test_only_last_non_empty_line_parsed(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = 'some log output\nmore output\n{"wakeAgent": false}'
|
||||
assert _parse_wake_gate(multi) is False
|
||||
|
||||
def test_trailing_blank_lines_ignored(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = '{"wakeAgent": false}\n\n\n'
|
||||
assert _parse_wake_gate(multi) is False
|
||||
|
||||
def test_non_last_json_line_does_not_gate(self):
|
||||
"""A JSON gate on an earlier line with plain text after it does NOT trigger."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = '{"wakeAgent": false}\nactually this is the real output'
|
||||
assert _parse_wake_gate(multi) is True
|
||||
|
||||
|
||||
class TestRunJobWakeGate:
|
||||
"""Integration tests for run_job wake-gate short-circuit."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_runtime_provider(self):
|
||||
"""Stub ``resolve_runtime_provider`` for wake-gate tests.
|
||||
|
||||
``run_job`` resolves the runtime provider BEFORE constructing
|
||||
``AIAgent``, so these tests must mock ``resolve_runtime_provider``
|
||||
in addition to ``AIAgent`` — otherwise in a hermetic CI env (no
|
||||
API keys), the resolver raises and the test fails before the
|
||||
patched AIAgent is ever reached.
|
||||
"""
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "test-key",
|
||||
"source": "stub",
|
||||
"requested_provider": None,
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
):
|
||||
yield
|
||||
|
||||
def _make_job(self, name="wake-gate-test", script="check.py"):
|
||||
"""Minimal valid cron job dict for run_job."""
|
||||
return {
|
||||
"id": f"job_{name}",
|
||||
"name": name,
|
||||
"prompt": "Do a thing",
|
||||
"schedule": "*/5 * * * *",
|
||||
"script": script,
|
||||
}
|
||||
|
||||
def test_wake_false_skips_agent_and_returns_silent(self, caplog):
|
||||
"""When _run_job_script output ends with {wakeAgent: false}, the agent
|
||||
is not invoked and run_job returns the SILENT marker so delivery is
|
||||
suppressed."""
|
||||
from cron.scheduler import SILENT_MARKER
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(True, '{"wakeAgent": false}')), \
|
||||
patch("run_agent.AIAgent") as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
assert success is True
|
||||
assert err is None
|
||||
assert final == SILENT_MARKER
|
||||
assert "Script gate returned `wakeAgent=false`" in doc
|
||||
agent_cls.assert_not_called()
|
||||
|
||||
def test_wake_true_runs_agent_with_injected_output(self):
|
||||
"""When the script returns {wakeAgent: true, data: ...}, the agent is
|
||||
invoked and the data line still shows up in the prompt."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
script_output = '{"wakeAgent": true, "data": {"new": 3}}'
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(True, script_output)), \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
agent_cls.assert_called_once()
|
||||
# The script output should be visible in the prompt passed to
|
||||
# run_conversation.
|
||||
call_kwargs = agent.run_conversation.call_args
|
||||
prompt_arg = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs.get("user_message", "")
|
||||
assert script_output in prompt_arg
|
||||
assert success is True
|
||||
assert err is None
|
||||
|
||||
def test_script_runs_only_once_on_wake(self):
|
||||
"""Wake-true path must not re-run the script inside _build_job_prompt
|
||||
(script would execute twice otherwise, wasting work and risking
|
||||
double-side-effects)."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
call_count = 0
|
||||
def _script_stub(path):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return (True, "regular output")
|
||||
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script", side_effect=_script_stub), \
|
||||
patch("run_agent.AIAgent", return_value=agent):
|
||||
scheduler.run_job(self._make_job())
|
||||
|
||||
assert call_count == 1, f"script ran {call_count}x, expected exactly 1"
|
||||
|
||||
def test_script_failure_does_not_trigger_gate(self):
|
||||
"""If _run_job_script returns success=False, the gate is NOT evaluated
|
||||
and the agent still runs (the failure is reported as context)."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
# Malicious or broken script whose stderr happens to contain the
|
||||
# gate JSON — we must NOT honor it because ran_ok is False.
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(False, '{"wakeAgent": false}')), \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
agent_cls.assert_called_once() # Agent DID wake despite the gate-like text
|
||||
|
||||
def test_no_script_path_runs_agent_normally(self):
|
||||
"""Regression: jobs without a script still work."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
job = self._make_job(script=None)
|
||||
job.pop("script", None)
|
||||
with patch.object(scheduler, "_run_job_script") as script_fn, \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
scheduler.run_job(job)
|
||||
|
||||
script_fn.assert_not_called()
|
||||
agent_cls.assert_called_once()
|
||||
|
||||
|
||||
class TestBuildJobPromptMissingSkill:
|
||||
"""Verify that a missing skill logs a warning and does not crash the job."""
|
||||
|
||||
@@ -1126,44 +1414,6 @@ class TestBuildJobPromptMissingSkill:
|
||||
assert "go" in result
|
||||
|
||||
|
||||
class TestTickAdvanceBeforeRun:
|
||||
"""Verify that tick() calls advance_next_run before run_job for crash safety."""
|
||||
|
||||
def test_advance_called_before_run_job(self, tmp_path):
|
||||
"""advance_next_run must be called before run_job to prevent crash-loop re-fires."""
|
||||
call_order = []
|
||||
|
||||
def fake_advance(job_id):
|
||||
call_order.append(("advance", job_id))
|
||||
return True
|
||||
|
||||
def fake_run_job(job):
|
||||
call_order.append(("run", job["id"]))
|
||||
return True, "output", "response", None
|
||||
|
||||
fake_job = {
|
||||
"id": "test-advance",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "cron", "expr": "15 6 * * *"},
|
||||
}
|
||||
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[fake_job]), \
|
||||
patch("cron.scheduler.advance_next_run", side_effect=fake_advance) as adv_mock, \
|
||||
patch("cron.scheduler.run_job", side_effect=fake_run_job), \
|
||||
patch("cron.scheduler.save_job_output", return_value=tmp_path / "out.md"), \
|
||||
patch("cron.scheduler.mark_job_run"), \
|
||||
patch("cron.scheduler._deliver_result"):
|
||||
from cron.scheduler import tick
|
||||
executed = tick(verbose=False)
|
||||
|
||||
assert executed == 1
|
||||
adv_mock.assert_called_once_with("test-advance")
|
||||
# advance must happen before run
|
||||
assert call_order == [("advance", "test-advance"), ("run", "test-advance")]
|
||||
|
||||
|
||||
class TestSendMediaViaAdapter:
|
||||
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
|
||||
|
||||
@@ -1207,12 +1457,3 @@ class TestSendMediaViaAdapter:
|
||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
|
||||
adapter.send_voice.assert_called_once()
|
||||
adapter.send_image_file.assert_called_once()
|
||||
|
||||
def test_single_failure_does_not_block_others(self):
|
||||
adapter = MagicMock()
|
||||
adapter.send_voice = AsyncMock(side_effect=RuntimeError("network error"))
|
||||
adapter.send_image_file = AsyncMock()
|
||||
media_files = [("/tmp/voice.ogg", False), ("/tmp/photo.png", False)]
|
||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j4"})
|
||||
adapter.send_voice.assert_called_once()
|
||||
adapter.send_image_file.assert_called_once()
|
||||
|
||||
147
tests/gateway/conftest.py
Normal file
147
tests/gateway/conftest.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Shared fixtures for gateway tests.
|
||||
|
||||
The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of
|
||||
the ``telegram`` package is registered in :data:`sys.modules` **before**
|
||||
any test file triggers ``from gateway.platforms.telegram import ...``.
|
||||
|
||||
Without this, ``pytest-xdist`` workers that happen to collect
|
||||
``test_telegram_caption_merge.py`` (bare top-level import, no per-file
|
||||
mock) first will cache ``ChatType = None`` from the production
|
||||
ImportError fallback, causing 30+ downstream test failures wherever
|
||||
``ChatType.GROUP`` / ``ChatType.SUPERGROUP`` is accessed.
|
||||
|
||||
Individual test files may still call their own ``_ensure_telegram_mock``
|
||||
— it short-circuits when the mock is already present.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _ensure_telegram_mock() -> None:
|
||||
"""Install a comprehensive telegram mock in sys.modules.
|
||||
|
||||
Idempotent — skips when the real library is already imported.
|
||||
Uses ``sys.modules[name] = mod`` (overwrite) instead of
|
||||
``setdefault`` so it wins even if a partial/broken import
|
||||
already cached a module with ``ChatType = None``.
|
||||
"""
|
||||
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||
return # Real library is installed — nothing to mock
|
||||
|
||||
mod = MagicMock()
|
||||
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
||||
mod.constants.ParseMode.MARKDOWN = "Markdown"
|
||||
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
||||
mod.constants.ParseMode.HTML = "HTML"
|
||||
mod.constants.ChatType.PRIVATE = "private"
|
||||
mod.constants.ChatType.GROUP = "group"
|
||||
mod.constants.ChatType.SUPERGROUP = "supergroup"
|
||||
mod.constants.ChatType.CHANNEL = "channel"
|
||||
|
||||
# Real exception classes so ``except (NetworkError, ...)`` clauses
|
||||
# in production code don't blow up with TypeError.
|
||||
mod.error.NetworkError = type("NetworkError", (OSError,), {})
|
||||
mod.error.TimedOut = type("TimedOut", (OSError,), {})
|
||||
mod.error.BadRequest = type("BadRequest", (Exception,), {})
|
||||
mod.error.Forbidden = type("Forbidden", (Exception,), {})
|
||||
mod.error.InvalidToken = type("InvalidToken", (Exception,), {})
|
||||
mod.error.RetryAfter = type("RetryAfter", (Exception,), {"retry_after": 1})
|
||||
mod.error.Conflict = type("Conflict", (Exception,), {})
|
||||
|
||||
# Update.ALL_TYPES used in start_polling()
|
||||
mod.Update.ALL_TYPES = []
|
||||
|
||||
for name in (
|
||||
"telegram",
|
||||
"telegram.ext",
|
||||
"telegram.constants",
|
||||
"telegram.request",
|
||||
):
|
||||
sys.modules[name] = mod
|
||||
sys.modules["telegram.error"] = mod.error
|
||||
|
||||
|
||||
def _ensure_discord_mock() -> None:
|
||||
"""Install a comprehensive discord mock in sys.modules.
|
||||
|
||||
Idempotent — skips when the real library is already imported.
|
||||
Uses ``sys.modules[name] = mod`` (overwrite) instead of
|
||||
``setdefault`` so it wins even if a partial/broken import already
|
||||
cached the module.
|
||||
|
||||
This mock is comprehensive — it includes **all** attributes needed by
|
||||
every gateway discord test file. Individual test files should call
|
||||
this function (it short-circuits when already present) rather than
|
||||
maintaining their own mock setup.
|
||||
"""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return # Real library is installed — nothing to mock
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.ui = SimpleNamespace(
|
||||
View=object,
|
||||
button=lambda *a, **k: (lambda fn: fn),
|
||||
Button=object,
|
||||
)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(
|
||||
success=1, primary=2, secondary=2, danger=3,
|
||||
green=1, grey=2, blurple=2, red=3,
|
||||
)
|
||||
discord_mod.Color = SimpleNamespace(
|
||||
orange=lambda: 1, green=lambda: 2, blue=lambda: 3,
|
||||
red=lambda: 4, purple=lambda: 5,
|
||||
)
|
||||
|
||||
# app_commands — needed by _register_slash_commands auto-registration
|
||||
class _FakeGroup:
|
||||
def __init__(self, *, name, description, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parent = parent
|
||||
self._children: dict = {}
|
||||
if parent is not None:
|
||||
parent.add_command(self)
|
||||
|
||||
def add_command(self, cmd):
|
||||
self._children[cmd.name] = cmd
|
||||
|
||||
class _FakeCommand:
|
||||
def __init__(self, *, name, description, callback, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.callback = callback
|
||||
self.parent = parent
|
||||
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
Group=_FakeGroup,
|
||||
Command=_FakeCommand,
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
for name in ("discord", "discord.ext", "discord.ext.commands"):
|
||||
sys.modules[name] = discord_mod
|
||||
sys.modules["discord.ext"] = ext_mod
|
||||
sys.modules["discord.ext.commands"] = commands_mod
|
||||
|
||||
|
||||
# Run at collection time — before any test file's module-level imports.
|
||||
_ensure_telegram_mock()
|
||||
_ensure_discord_mock()
|
||||
@@ -108,6 +108,7 @@ def make_restart_runner(
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store._entries = {}
|
||||
runner.delivery_router = MagicMock()
|
||||
|
||||
platform_adapter = adapter or RestartTestAdapter()
|
||||
|
||||
@@ -258,3 +258,785 @@ class TestAgentCacheLifecycle:
|
||||
cb3 = lambda *a: None
|
||||
agent.tool_progress_callback = cb3
|
||||
assert agent.tool_progress_callback is cb3
|
||||
|
||||
|
||||
class TestAgentCacheBoundedGrowth:
|
||||
"""LRU cap and idle-TTL eviction prevent unbounded cache growth."""
|
||||
|
||||
def _bounded_runner(self):
|
||||
"""Runner with an OrderedDict cache (matches real gateway init)."""
|
||||
from collections import OrderedDict
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = OrderedDict()
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
return runner
|
||||
|
||||
def _fake_agent(self, last_activity: float | None = None):
|
||||
"""Lightweight stand-in; real AIAgent is heavy to construct."""
|
||||
m = MagicMock()
|
||||
if last_activity is not None:
|
||||
m._last_activity_ts = last_activity
|
||||
else:
|
||||
import time as _t
|
||||
m._last_activity_ts = _t.time()
|
||||
return m
|
||||
|
||||
def test_cap_evicts_lru_when_exceeded(self, monkeypatch):
|
||||
"""Inserting past _AGENT_CACHE_MAX_SIZE pops the oldest entry."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 3)
|
||||
runner = self._bounded_runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
for i in range(3):
|
||||
runner._agent_cache[f"s{i}"] = (self._fake_agent(), f"sig{i}")
|
||||
|
||||
# Insert a 4th — oldest (s0) must be evicted.
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["s3"] = (self._fake_agent(), "sig3")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
assert "s0" not in runner._agent_cache
|
||||
assert "s3" in runner._agent_cache
|
||||
assert len(runner._agent_cache) == 3
|
||||
|
||||
def test_cap_respects_move_to_end(self, monkeypatch):
|
||||
"""Entries refreshed via move_to_end are NOT evicted as 'oldest'."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 3)
|
||||
runner = self._bounded_runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
for i in range(3):
|
||||
runner._agent_cache[f"s{i}"] = (self._fake_agent(), f"sig{i}")
|
||||
|
||||
# Touch s0 — it is now MRU, so s1 becomes LRU.
|
||||
runner._agent_cache.move_to_end("s0")
|
||||
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["s3"] = (self._fake_agent(), "sig3")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
assert "s0" in runner._agent_cache # rescued by move_to_end
|
||||
assert "s1" not in runner._agent_cache # now oldest → evicted
|
||||
assert "s3" in runner._agent_cache
|
||||
|
||||
def test_cap_triggers_cleanup_thread(self, monkeypatch):
|
||||
"""Evicted agent has release_clients() called for it (soft cleanup).
|
||||
|
||||
Uses the soft path (_release_evicted_agent_soft), NOT the hard
|
||||
_cleanup_agent_resources — cache eviction must not tear down
|
||||
per-task state (terminal/browser/bg procs).
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
|
||||
runner = self._bounded_runner()
|
||||
|
||||
release_calls: list = []
|
||||
cleanup_calls: list = []
|
||||
# Intercept both paths; only release_clients path should fire.
|
||||
def _soft(agent):
|
||||
release_calls.append(agent)
|
||||
runner._release_evicted_agent_soft = _soft
|
||||
runner._cleanup_agent_resources = lambda a: cleanup_calls.append(a)
|
||||
|
||||
old_agent = self._fake_agent()
|
||||
new_agent = self._fake_agent()
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["old"] = (old_agent, "sig_old")
|
||||
runner._agent_cache["new"] = (new_agent, "sig_new")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# Cleanup is dispatched to a daemon thread; join briefly to observe.
|
||||
import time as _t
|
||||
deadline = _t.time() + 2.0
|
||||
while _t.time() < deadline and not release_calls:
|
||||
_t.sleep(0.02)
|
||||
assert old_agent in release_calls
|
||||
assert new_agent not in release_calls
|
||||
# Hard-cleanup path must NOT have fired — that's for session expiry only.
|
||||
assert cleanup_calls == []
|
||||
|
||||
def test_idle_ttl_sweep_evicts_stale_agents(self, monkeypatch):
|
||||
"""_sweep_idle_cached_agents removes agents idle past the TTL."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.05)
|
||||
runner = self._bounded_runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
import time as _t
|
||||
fresh = self._fake_agent(last_activity=_t.time())
|
||||
stale = self._fake_agent(last_activity=_t.time() - 10.0)
|
||||
runner._agent_cache["fresh"] = (fresh, "s1")
|
||||
runner._agent_cache["stale"] = (stale, "s2")
|
||||
|
||||
evicted = runner._sweep_idle_cached_agents()
|
||||
assert evicted == 1
|
||||
assert "stale" not in runner._agent_cache
|
||||
assert "fresh" in runner._agent_cache
|
||||
|
||||
def test_idle_sweep_skips_agents_without_activity_ts(self, monkeypatch):
|
||||
"""Agents missing _last_activity_ts are left alone (defensive)."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
|
||||
runner = self._bounded_runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
no_ts = MagicMock(spec=[]) # no _last_activity_ts attribute
|
||||
runner._agent_cache["s"] = (no_ts, "sig")
|
||||
|
||||
assert runner._sweep_idle_cached_agents() == 0
|
||||
assert "s" in runner._agent_cache
|
||||
|
||||
def test_plain_dict_cache_is_tolerated(self):
|
||||
"""Test fixtures using plain {} don't crash _enforce_agent_cache_cap."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = {} # plain dict, not OrderedDict
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
# Should be a no-op rather than raising.
|
||||
with runner._agent_cache_lock:
|
||||
for i in range(200):
|
||||
runner._agent_cache[f"s{i}"] = (MagicMock(), f"sig{i}")
|
||||
runner._enforce_agent_cache_cap() # no crash, no eviction
|
||||
|
||||
assert len(runner._agent_cache) == 200
|
||||
|
||||
def test_main_lookup_updates_lru_order(self, monkeypatch):
|
||||
"""Cache hit via the main-lookup path refreshes LRU position."""
|
||||
runner = self._bounded_runner()
|
||||
|
||||
a0 = self._fake_agent()
|
||||
a1 = self._fake_agent()
|
||||
a2 = self._fake_agent()
|
||||
runner._agent_cache["s0"] = (a0, "sig0")
|
||||
runner._agent_cache["s1"] = (a1, "sig1")
|
||||
runner._agent_cache["s2"] = (a2, "sig2")
|
||||
|
||||
# Simulate what _process_message_background does on a cache hit
|
||||
# (minus the agent-state reset which isn't relevant here).
|
||||
with runner._agent_cache_lock:
|
||||
cached = runner._agent_cache.get("s0")
|
||||
if cached and hasattr(runner._agent_cache, "move_to_end"):
|
||||
runner._agent_cache.move_to_end("s0")
|
||||
|
||||
# After the hit, insertion order should be s1, s2, s0.
|
||||
assert list(runner._agent_cache.keys()) == ["s1", "s2", "s0"]
|
||||
|
||||
|
||||
class TestAgentCacheActiveSafety:
|
||||
"""Safety: eviction must not tear down agents currently mid-turn.
|
||||
|
||||
AIAgent.close() kills process_registry entries for the task, cleans
|
||||
the terminal sandbox, closes the OpenAI client, and cascades
|
||||
.close() into active child subagents. Calling it while the agent
|
||||
is still processing would crash the in-flight request. These tests
|
||||
pin that eviction skips any agent present in _running_agents.
|
||||
"""
|
||||
|
||||
def _runner(self):
|
||||
from collections import OrderedDict
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = OrderedDict()
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._running_agents = {}
|
||||
return runner
|
||||
|
||||
def _fake_agent(self, idle_seconds: float = 0.0):
|
||||
import time as _t
|
||||
m = MagicMock()
|
||||
m._last_activity_ts = _t.time() - idle_seconds
|
||||
return m
|
||||
|
||||
def test_cap_skips_active_lru_entry(self, monkeypatch):
|
||||
"""Active LRU entry is skipped; cache stays over cap rather than
|
||||
compensating by evicting a newer entry.
|
||||
|
||||
Rationale: evicting a more-recent entry just because the oldest
|
||||
slot is temporarily locked would punish the most recently-
|
||||
inserted session (which has no cache to preserve) to protect
|
||||
one that happens to be mid-turn. Better to let the cache stay
|
||||
transiently over cap and re-check on the next insert.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 2)
|
||||
runner = self._runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
active = self._fake_agent()
|
||||
idle_a = self._fake_agent()
|
||||
idle_b = self._fake_agent()
|
||||
|
||||
# Insertion order: active (oldest), idle_a, idle_b.
|
||||
runner._agent_cache["session-active"] = (active, "sig")
|
||||
runner._agent_cache["session-idle-a"] = (idle_a, "sig")
|
||||
runner._agent_cache["session-idle-b"] = (idle_b, "sig")
|
||||
|
||||
# Mark `active` as mid-turn — it's LRU, but protected.
|
||||
runner._running_agents["session-active"] = active
|
||||
|
||||
with runner._agent_cache_lock:
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# All three remain; no eviction ran, no cleanup dispatched.
|
||||
assert "session-active" in runner._agent_cache
|
||||
assert "session-idle-a" in runner._agent_cache
|
||||
assert "session-idle-b" in runner._agent_cache
|
||||
assert runner._cleanup_agent_resources.call_count == 0
|
||||
|
||||
def test_cap_evicts_when_multiple_excess_and_some_inactive(self, monkeypatch):
|
||||
"""Mixed active/idle in the LRU excess window: only the idle ones go.
|
||||
|
||||
With CAP=2 and 4 entries, excess=2 (the two oldest). If the
|
||||
oldest is active and the next is idle, we evict exactly one.
|
||||
Cache ends at CAP+1, which is still better than unbounded.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 2)
|
||||
runner = self._runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
oldest_active = self._fake_agent()
|
||||
idle_second = self._fake_agent()
|
||||
idle_third = self._fake_agent()
|
||||
idle_fourth = self._fake_agent()
|
||||
|
||||
runner._agent_cache["s1"] = (oldest_active, "sig")
|
||||
runner._agent_cache["s2"] = (idle_second, "sig") # in excess window, idle
|
||||
runner._agent_cache["s3"] = (idle_third, "sig")
|
||||
runner._agent_cache["s4"] = (idle_fourth, "sig")
|
||||
|
||||
runner._running_agents["s1"] = oldest_active # oldest is mid-turn
|
||||
|
||||
with runner._agent_cache_lock:
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# s1 protected (active), s2 evicted (idle + in excess window),
|
||||
# s3 and s4 untouched (outside excess window).
|
||||
assert "s1" in runner._agent_cache
|
||||
assert "s2" not in runner._agent_cache
|
||||
assert "s3" in runner._agent_cache
|
||||
assert "s4" in runner._agent_cache
|
||||
|
||||
def test_cap_leaves_cache_over_limit_if_all_active(self, monkeypatch, caplog):
|
||||
"""If every over-cap entry is mid-turn, the cache stays over cap.
|
||||
|
||||
Better to temporarily exceed the cap than to crash an in-flight
|
||||
turn by tearing down its clients.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
import logging as _logging
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
|
||||
runner = self._runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
a1 = self._fake_agent()
|
||||
a2 = self._fake_agent()
|
||||
a3 = self._fake_agent()
|
||||
runner._agent_cache["s1"] = (a1, "sig")
|
||||
runner._agent_cache["s2"] = (a2, "sig")
|
||||
runner._agent_cache["s3"] = (a3, "sig")
|
||||
|
||||
# All three are mid-turn.
|
||||
runner._running_agents["s1"] = a1
|
||||
runner._running_agents["s2"] = a2
|
||||
runner._running_agents["s3"] = a3
|
||||
|
||||
with caplog.at_level(_logging.WARNING, logger="gateway.run"):
|
||||
with runner._agent_cache_lock:
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# Cache unchanged because eviction had to skip every candidate.
|
||||
assert len(runner._agent_cache) == 3
|
||||
# _cleanup_agent_resources must NOT have been scheduled.
|
||||
assert runner._cleanup_agent_resources.call_count == 0
|
||||
# And we logged a warning so operators can see the condition.
|
||||
assert any("mid-turn" in r.message for r in caplog.records)
|
||||
|
||||
def test_cap_pending_sentinel_does_not_block_eviction(self, monkeypatch):
|
||||
"""_AGENT_PENDING_SENTINEL in _running_agents is treated as 'not active'.
|
||||
|
||||
The sentinel is set while an agent is being CONSTRUCTED, before the
|
||||
real AIAgent instance exists. Cached agents from other sessions
|
||||
can still be evicted safely.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
|
||||
runner = self._runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
a1 = self._fake_agent()
|
||||
a2 = self._fake_agent()
|
||||
runner._agent_cache["s1"] = (a1, "sig")
|
||||
runner._agent_cache["s2"] = (a2, "sig")
|
||||
# Another session is mid-creation — sentinel, no real agent yet.
|
||||
runner._running_agents["s3-being-created"] = _AGENT_PENDING_SENTINEL
|
||||
|
||||
with runner._agent_cache_lock:
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
assert "s1" not in runner._agent_cache # evicted normally
|
||||
assert "s2" in runner._agent_cache
|
||||
|
||||
def test_idle_sweep_skips_active_agent(self, monkeypatch):
|
||||
"""Idle-TTL sweep must not tear down an active agent even if 'stale'."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
|
||||
runner = self._runner()
|
||||
runner._cleanup_agent_resources = MagicMock()
|
||||
|
||||
old_but_active = self._fake_agent(idle_seconds=10.0)
|
||||
runner._agent_cache["s1"] = (old_but_active, "sig")
|
||||
runner._running_agents["s1"] = old_but_active
|
||||
|
||||
evicted = runner._sweep_idle_cached_agents()
|
||||
|
||||
assert evicted == 0
|
||||
assert "s1" in runner._agent_cache
|
||||
assert runner._cleanup_agent_resources.call_count == 0
|
||||
|
||||
def test_eviction_does_not_close_active_agent_client(self, monkeypatch):
|
||||
"""Live test: evicting an active agent does NOT null its .client.
|
||||
|
||||
This reproduces the original concern — if eviction fired while an
|
||||
agent was mid-turn, `agent.close()` would set `self.client = None`
|
||||
and the next API call inside the loop would crash. With the
|
||||
active-agent skip, the client stays intact.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", 1)
|
||||
runner = self._runner()
|
||||
|
||||
# Build a proper fake agent whose close() matches AIAgent's contract.
|
||||
active = MagicMock()
|
||||
active._last_activity_ts = __import__("time").time()
|
||||
active.client = MagicMock() # simulate an OpenAI client
|
||||
def _real_close():
|
||||
active.client = None # mirrors run_agent.py:3299
|
||||
active.close = _real_close
|
||||
active.shutdown_memory_provider = MagicMock()
|
||||
|
||||
idle = self._fake_agent()
|
||||
|
||||
runner._agent_cache["active-session"] = (active, "sig")
|
||||
runner._agent_cache["idle-session"] = (idle, "sig")
|
||||
runner._running_agents["active-session"] = active
|
||||
|
||||
# Real cleanup function, not mocked — we want to see whether close()
|
||||
# runs on the active agent. (It shouldn't.)
|
||||
with runner._agent_cache_lock:
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# Let any eviction cleanup threads drain.
|
||||
import time as _t
|
||||
_t.sleep(0.2)
|
||||
|
||||
# The ACTIVE agent's client must still be usable.
|
||||
assert active.client is not None, (
|
||||
"Active agent's client was closed by eviction — "
|
||||
"running turn would crash on its next API call."
|
||||
)
|
||||
|
||||
|
||||
class TestAgentCacheSpilloverLive:
|
||||
"""Live E2E: fill cache with real AIAgent instances and stress it."""
|
||||
|
||||
def _runner(self):
|
||||
from collections import OrderedDict
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = OrderedDict()
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._running_agents = {}
|
||||
return runner
|
||||
|
||||
def _real_agent(self):
|
||||
"""A genuine AIAgent; no API calls are made during these tests."""
|
||||
from run_agent import AIAgent
|
||||
return AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
platform="telegram",
|
||||
)
|
||||
|
||||
def test_fill_to_cap_then_spillover(self, monkeypatch):
|
||||
"""Fill to cap with real agents, insert one more, oldest evicted."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
CAP = 8
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
|
||||
runner = self._runner()
|
||||
|
||||
agents = [self._real_agent() for _ in range(CAP)]
|
||||
for i, a in enumerate(agents):
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache[f"s{i}"] = (a, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
assert len(runner._agent_cache) == CAP
|
||||
|
||||
# Spillover insertion.
|
||||
newcomer = self._real_agent()
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["new"] = (newcomer, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
# Oldest (s0) evicted, cap still CAP.
|
||||
assert "s0" not in runner._agent_cache
|
||||
assert "new" in runner._agent_cache
|
||||
assert len(runner._agent_cache) == CAP
|
||||
|
||||
# Clean up so pytest doesn't leak resources.
|
||||
for a in agents + [newcomer]:
|
||||
try:
|
||||
a.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_spillover_all_active_keeps_cache_over_cap(self, monkeypatch, caplog):
|
||||
"""Every slot active: cache goes over cap, no one gets torn down."""
|
||||
from gateway import run as gw_run
|
||||
import logging as _logging
|
||||
|
||||
CAP = 4
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
|
||||
runner = self._runner()
|
||||
|
||||
agents = [self._real_agent() for _ in range(CAP)]
|
||||
for i, a in enumerate(agents):
|
||||
runner._agent_cache[f"s{i}"] = (a, "sig")
|
||||
runner._running_agents[f"s{i}"] = a # every session mid-turn
|
||||
|
||||
newcomer = self._real_agent()
|
||||
with caplog.at_level(_logging.WARNING, logger="gateway.run"):
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["new"] = (newcomer, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
assert len(runner._agent_cache) == CAP + 1 # temporarily over cap
|
||||
# All existing agents still usable.
|
||||
for i, a in enumerate(agents):
|
||||
assert a.client is not None, f"s{i} got closed while active!"
|
||||
# And we warned operators.
|
||||
assert any("mid-turn" in r.message for r in caplog.records)
|
||||
|
||||
for a in agents + [newcomer]:
|
||||
try:
|
||||
a.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_concurrent_inserts_settle_at_cap(self, monkeypatch):
|
||||
"""Many threads inserting in parallel end with len(cache) == CAP."""
|
||||
from gateway import run as gw_run
|
||||
|
||||
CAP = 16
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
|
||||
runner = self._runner()
|
||||
|
||||
N_THREADS = 8
|
||||
PER_THREAD = 20 # 8 * 20 = 160 inserts into a 16-slot cache
|
||||
|
||||
def worker(tid: int):
|
||||
for j in range(PER_THREAD):
|
||||
a = self._real_agent()
|
||||
key = f"t{tid}-s{j}"
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache[key] = (a, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(t,), daemon=True)
|
||||
for t in range(N_THREADS)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=30)
|
||||
assert not t.is_alive(), "Worker thread hung — possible deadlock?"
|
||||
|
||||
# Let daemon cleanup threads settle.
|
||||
import time as _t
|
||||
_t.sleep(0.5)
|
||||
|
||||
assert len(runner._agent_cache) == CAP, (
|
||||
f"Expected exactly {CAP} entries after concurrent inserts, "
|
||||
f"got {len(runner._agent_cache)}."
|
||||
)
|
||||
|
||||
def test_evicted_session_next_turn_gets_fresh_agent(self, monkeypatch):
|
||||
"""After eviction, the same session_key can insert a fresh agent.
|
||||
|
||||
Simulates the real spillover flow: evicted session sends another
|
||||
message, which builds a new AIAgent and re-enters the cache.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
|
||||
CAP = 2
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP)
|
||||
runner = self._runner()
|
||||
|
||||
a0 = self._real_agent()
|
||||
a1 = self._real_agent()
|
||||
runner._agent_cache["sA"] = (a0, "sig")
|
||||
runner._agent_cache["sB"] = (a1, "sig")
|
||||
|
||||
# 3rd session forces sA (oldest) out.
|
||||
a2 = self._real_agent()
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["sC"] = (a2, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
assert "sA" not in runner._agent_cache
|
||||
|
||||
# Let the eviction cleanup thread run.
|
||||
import time as _t
|
||||
_t.sleep(0.3)
|
||||
|
||||
# Now sA's user sends another message → a fresh agent goes in.
|
||||
a0_new = self._real_agent()
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["sA"] = (a0_new, "sig")
|
||||
runner._enforce_agent_cache_cap()
|
||||
|
||||
assert "sA" in runner._agent_cache
|
||||
assert runner._agent_cache["sA"][0] is a0_new # the new one, not stale
|
||||
# Fresh agent is usable.
|
||||
assert a0_new.client is not None
|
||||
|
||||
for a in (a0, a1, a2, a0_new):
|
||||
try:
|
||||
a.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestAgentCacheIdleResume:
|
||||
"""End-to-end: idle-TTL-evicted session resumes cleanly with task state.
|
||||
|
||||
Real-world scenario: user leaves a Telegram session open for 2+ hours.
|
||||
Idle-TTL evicts their cached agent. They come back and send a message.
|
||||
The new agent built for the same session_id must inherit:
|
||||
- Conversation history (from SessionStore — outside cache concern)
|
||||
- Terminal sandbox (same task_id → same _active_environments entry)
|
||||
- Browser daemon (same task_id → same browser session)
|
||||
- Background processes (same task_id → same process_registry entries)
|
||||
The ONLY thing that should reset is the LLM client pool (rebuilt fresh).
|
||||
"""
|
||||
|
||||
def _runner(self):
|
||||
from collections import OrderedDict
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = OrderedDict()
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._running_agents = {}
|
||||
return runner
|
||||
|
||||
def test_release_clients_does_not_touch_process_registry(self, monkeypatch):
|
||||
"""release_clients must not call process_registry.kill_all for task_id."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id="idle-resume-test-session",
|
||||
)
|
||||
|
||||
# Spy on process_registry.kill_all — it MUST NOT be called.
|
||||
from tools import process_registry as _pr
|
||||
kill_all_calls: list = []
|
||||
original_kill_all = _pr.process_registry.kill_all
|
||||
_pr.process_registry.kill_all = lambda **kw: kill_all_calls.append(kw)
|
||||
try:
|
||||
agent.release_clients()
|
||||
finally:
|
||||
_pr.process_registry.kill_all = original_kill_all
|
||||
try:
|
||||
agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert kill_all_calls == [], (
|
||||
f"release_clients() called process_registry.kill_all — would "
|
||||
f"kill user's bg processes on cache eviction. Calls: {kill_all_calls}"
|
||||
)
|
||||
|
||||
def test_release_clients_does_not_touch_terminal_or_browser(self, monkeypatch):
|
||||
"""release_clients must not call cleanup_vm or cleanup_browser."""
|
||||
from run_agent import AIAgent
|
||||
from tools import terminal_tool as _tt
|
||||
from tools import browser_tool as _bt
|
||||
|
||||
agent = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id="idle-resume-test-2",
|
||||
)
|
||||
|
||||
vm_calls: list = []
|
||||
browser_calls: list = []
|
||||
original_vm = _tt.cleanup_vm
|
||||
original_browser = _bt.cleanup_browser
|
||||
_tt.cleanup_vm = lambda tid: vm_calls.append(tid)
|
||||
_bt.cleanup_browser = lambda tid: browser_calls.append(tid)
|
||||
try:
|
||||
agent.release_clients()
|
||||
finally:
|
||||
_tt.cleanup_vm = original_vm
|
||||
_bt.cleanup_browser = original_browser
|
||||
try:
|
||||
agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert vm_calls == [], (
|
||||
f"release_clients() tore down terminal sandbox — user's cwd, "
|
||||
f"env, and bg shells would be gone on resume. Calls: {vm_calls}"
|
||||
)
|
||||
assert browser_calls == [], (
|
||||
f"release_clients() tore down browser session — user's open "
|
||||
f"tabs and cookies gone on resume. Calls: {browser_calls}"
|
||||
)
|
||||
|
||||
def test_release_clients_closes_llm_client(self):
|
||||
"""release_clients IS expected to close the OpenAI/httpx client."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
)
|
||||
# Clients are lazy-built; force one to exist so we can verify close.
|
||||
assert agent.client is not None # __init__ builds it
|
||||
|
||||
agent.release_clients()
|
||||
|
||||
# Post-release: client reference is dropped (memory freed).
|
||||
assert agent.client is None
|
||||
|
||||
def test_close_vs_release_full_teardown_difference(self, monkeypatch):
|
||||
"""close() tears down task state; release_clients() does not.
|
||||
|
||||
This pins the semantic contract: session-expiry path uses close()
|
||||
(full teardown — session is done), cache-eviction path uses
|
||||
release_clients() (soft — session may resume).
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
from tools import terminal_tool as _tt
|
||||
|
||||
# Agent A: evicted from cache (soft) — terminal survives.
|
||||
# Agent B: session expired (hard) — terminal torn down.
|
||||
agent_a = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id="soft-session",
|
||||
)
|
||||
agent_b = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id="hard-session",
|
||||
)
|
||||
|
||||
vm_calls: list = []
|
||||
original_vm = _tt.cleanup_vm
|
||||
_tt.cleanup_vm = lambda tid: vm_calls.append(tid)
|
||||
try:
|
||||
agent_a.release_clients() # cache eviction
|
||||
agent_b.close() # session expiry
|
||||
finally:
|
||||
_tt.cleanup_vm = original_vm
|
||||
try:
|
||||
agent_a.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only agent_b's task_id should appear in cleanup calls.
|
||||
assert "hard-session" in vm_calls
|
||||
assert "soft-session" not in vm_calls
|
||||
|
||||
def test_idle_evicted_session_rebuild_inherits_task_id(self, monkeypatch):
|
||||
"""After idle-TTL eviction, a fresh agent with the same session_id
|
||||
gets the same task_id — so tool state (terminal/browser/bg procs)
|
||||
that persisted across eviction is reachable via the new agent.
|
||||
"""
|
||||
from gateway import run as gw_run
|
||||
from run_agent import AIAgent
|
||||
|
||||
monkeypatch.setattr(gw_run, "_AGENT_CACHE_IDLE_TTL_SECS", 0.01)
|
||||
runner = self._runner()
|
||||
|
||||
# Build an agent representing a stale (idle) session.
|
||||
SESSION_ID = "long-lived-user-session"
|
||||
old = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id=SESSION_ID,
|
||||
)
|
||||
old._last_activity_ts = 0.0 # force idle
|
||||
runner._agent_cache["sKey"] = (old, "sig")
|
||||
|
||||
# Simulate the idle-TTL sweep firing.
|
||||
runner._sweep_idle_cached_agents()
|
||||
assert "sKey" not in runner._agent_cache
|
||||
|
||||
# Wait for the daemon thread doing release_clients() to finish.
|
||||
import time as _t
|
||||
_t.sleep(0.3)
|
||||
|
||||
# Old agent's client is gone (soft cleanup fired).
|
||||
assert old.client is None
|
||||
|
||||
# User comes back — new agent built for the SAME session_id.
|
||||
new_agent = AIAgent(
|
||||
model="anthropic/claude-sonnet-4", api_key="test",
|
||||
base_url="https://openrouter.ai/api/v1", provider="openrouter",
|
||||
max_iterations=5, quiet_mode=True,
|
||||
skip_context_files=True, skip_memory=True,
|
||||
session_id=SESSION_ID,
|
||||
)
|
||||
|
||||
# Same session_id means same task_id routed to tools. The new
|
||||
# agent inherits any per-task state (terminal sandbox etc.) that
|
||||
# was preserved across eviction.
|
||||
assert new_agent.session_id == old.session_id == SESSION_ID
|
||||
# And it has a fresh working client.
|
||||
assert new_agent.client is not None
|
||||
|
||||
try:
|
||||
new_agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1016,6 +1016,47 @@ class TestResponsesEndpoint:
|
||||
assert len(call_kwargs["conversation_history"]) > 0
|
||||
assert call_kwargs["user_message"] == "Now add 1 more"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_previous_response_id_preserves_session(self, adapter):
|
||||
"""Chained responses via previous_response_id reuse the same session_id."""
|
||||
mock_result = {
|
||||
"final_response": "ok",
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
"api_calls": 1,
|
||||
}
|
||||
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
# First request — establishes a session
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, usage)
|
||||
resp1 = await cli.post(
|
||||
"/v1/responses",
|
||||
json={"model": "hermes-agent", "input": "Hello"},
|
||||
)
|
||||
assert resp1.status == 200
|
||||
first_session_id = mock_run.call_args.kwargs["session_id"]
|
||||
data1 = await resp1.json()
|
||||
response_id = data1["id"]
|
||||
|
||||
# Second request — chains from the first
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, usage)
|
||||
resp2 = await cli.post(
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"input": "Follow up",
|
||||
"previous_response_id": response_id,
|
||||
},
|
||||
)
|
||||
assert resp2.status == 200
|
||||
second_session_id = mock_run.call_args.kwargs["session_id"]
|
||||
|
||||
# Session must be the same across the chain
|
||||
assert first_session_id == second_session_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_previous_response_id_returns_404(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
@@ -1115,6 +1156,134 @@ class TestResponsesEndpoint:
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
class TestResponsesStreaming:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_true_returns_responses_sse(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
async def _mock_run_agent(**kwargs):
|
||||
cb = kwargs.get("stream_delta_callback")
|
||||
if cb:
|
||||
cb("Hello")
|
||||
cb(" world")
|
||||
return (
|
||||
{"final_response": "Hello world", "messages": [], "api_calls": 1},
|
||||
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
)
|
||||
|
||||
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
json={"model": "hermes-agent", "input": "hi", "stream": True},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert "text/event-stream" in resp.headers.get("Content-Type", "")
|
||||
body = await resp.text()
|
||||
assert "event: response.created" in body
|
||||
assert "event: response.output_text.delta" in body
|
||||
assert "event: response.output_text.done" in body
|
||||
assert "event: response.completed" in body
|
||||
assert '"sequence_number":' in body
|
||||
assert '"logprobs": []' in body
|
||||
assert "Hello" in body
|
||||
assert " world" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_emits_function_call_and_output_items(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
async def _mock_run_agent(**kwargs):
|
||||
start_cb = kwargs.get("tool_start_callback")
|
||||
complete_cb = kwargs.get("tool_complete_callback")
|
||||
text_cb = kwargs.get("stream_delta_callback")
|
||||
if start_cb:
|
||||
start_cb("call_123", "read_file", {"path": "/tmp/test.txt"})
|
||||
if complete_cb:
|
||||
complete_cb("call_123", "read_file", {"path": "/tmp/test.txt"}, '{"content":"hello"}')
|
||||
if text_cb:
|
||||
text_cb("Done.")
|
||||
return (
|
||||
{
|
||||
"final_response": "Done.",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path":"/tmp/test.txt"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"content": '{"content":"hello"}',
|
||||
},
|
||||
],
|
||||
"api_calls": 1,
|
||||
},
|
||||
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
)
|
||||
|
||||
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
json={"model": "hermes-agent", "input": "read the file", "stream": True},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
assert "event: response.output_item.added" in body
|
||||
assert "event: response.output_item.done" in body
|
||||
assert body.count("event: response.output_item.done") >= 2
|
||||
assert '"type": "function_call"' in body
|
||||
assert '"type": "function_call_output"' in body
|
||||
assert '"call_id": "call_123"' in body
|
||||
assert '"name": "read_file"' in body
|
||||
assert '"output": [{"type": "input_text", "text": "{\\"content\\":\\"hello\\"}"}]' in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamed_response_is_stored_for_get(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
async def _mock_run_agent(**kwargs):
|
||||
cb = kwargs.get("stream_delta_callback")
|
||||
if cb:
|
||||
cb("Stored response")
|
||||
return (
|
||||
{"final_response": "Stored response", "messages": [], "api_calls": 1},
|
||||
{"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
|
||||
)
|
||||
|
||||
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
json={"model": "hermes-agent", "input": "store this", "stream": True},
|
||||
)
|
||||
body = await resp.text()
|
||||
response_id = None
|
||||
for line in body.splitlines():
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
payload = json.loads(line[len("data: "):])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if payload.get("type") == "response.completed":
|
||||
response_id = payload["response"]["id"]
|
||||
break
|
||||
assert response_id
|
||||
|
||||
get_resp = await cli.get(f"/v1/responses/{response_id}")
|
||||
assert get_resp.status == 200
|
||||
data = await get_resp.json()
|
||||
assert data["id"] == response_id
|
||||
assert data["status"] == "completed"
|
||||
assert data["output"][-1]["content"][0]["text"] == "Stored response"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth on endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
308
tests/gateway/test_api_server_multimodal.py
Normal file
308
tests/gateway/test_api_server_multimodal.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""End-to-end tests for inline image inputs on /v1/chat/completions and /v1/responses.
|
||||
|
||||
Covers the multimodal normalization path added to the API server. Unlike the
|
||||
adapter-level tests that patch ``_run_agent``, these tests patch
|
||||
``AIAgent.run_conversation`` instead so the adapter's full request-handling
|
||||
path (including the ``run_agent`` prologue that used to crash on list content)
|
||||
executes against a real aiohttp app.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.api_server import (
|
||||
APIServerAdapter,
|
||||
_content_has_visible_payload,
|
||||
_normalize_multimodal_content,
|
||||
cors_middleware,
|
||||
security_headers_middleware,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-function tests for _normalize_multimodal_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeMultimodalContent:
|
||||
def test_string_passthrough(self):
|
||||
assert _normalize_multimodal_content("hello") == "hello"
|
||||
|
||||
def test_none_returns_empty_string(self):
|
||||
assert _normalize_multimodal_content(None) == ""
|
||||
|
||||
def test_text_only_list_collapses_to_string(self):
|
||||
content = [{"type": "text", "text": "hi"}, {"type": "text", "text": "there"}]
|
||||
assert _normalize_multimodal_content(content) == "hi\nthere"
|
||||
|
||||
def test_responses_input_text_canonicalized(self):
|
||||
content = [{"type": "input_text", "text": "hello"}]
|
||||
assert _normalize_multimodal_content(content) == "hello"
|
||||
|
||||
def test_image_url_preserved_with_text(self):
|
||||
content = [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}},
|
||||
]
|
||||
out = _normalize_multimodal_content(content)
|
||||
assert isinstance(out, list)
|
||||
assert out == [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}},
|
||||
]
|
||||
|
||||
def test_input_image_converted_to_canonical_shape(self):
|
||||
content = [
|
||||
{"type": "input_text", "text": "hi"},
|
||||
{"type": "input_image", "image_url": "https://example.com/cat.png"},
|
||||
]
|
||||
out = _normalize_multimodal_content(content)
|
||||
assert out == [
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
|
||||
]
|
||||
|
||||
def test_data_image_url_accepted(self):
|
||||
content = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}]
|
||||
out = _normalize_multimodal_content(content)
|
||||
assert out == [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}]
|
||||
|
||||
def test_non_image_data_url_rejected(self):
|
||||
content = [{"type": "image_url", "image_url": {"url": "data:text/plain;base64,SGVsbG8="}}]
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content(content)
|
||||
assert str(exc.value).startswith("unsupported_content_type:")
|
||||
|
||||
def test_file_part_rejected(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content([{"type": "file", "file": {"file_id": "f_1"}}])
|
||||
assert str(exc.value).startswith("unsupported_content_type:")
|
||||
|
||||
def test_input_file_part_rejected(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content([{"type": "input_file", "file_id": "f_1"}])
|
||||
assert str(exc.value).startswith("unsupported_content_type:")
|
||||
|
||||
def test_missing_url_rejected(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content([{"type": "image_url", "image_url": {}}])
|
||||
assert str(exc.value).startswith("invalid_image_url:")
|
||||
|
||||
def test_bad_scheme_rejected(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content([{"type": "image_url", "image_url": {"url": "ftp://example.com/x.png"}}])
|
||||
assert str(exc.value).startswith("invalid_image_url:")
|
||||
|
||||
def test_unknown_part_type_rejected(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_normalize_multimodal_content([{"type": "audio", "audio": {}}])
|
||||
assert str(exc.value).startswith("unsupported_content_type:")
|
||||
|
||||
|
||||
class TestContentHasVisiblePayload:
|
||||
def test_non_empty_string(self):
|
||||
assert _content_has_visible_payload("hello")
|
||||
|
||||
def test_whitespace_only_string(self):
|
||||
assert not _content_has_visible_payload(" ")
|
||||
|
||||
def test_list_with_image_only(self):
|
||||
assert _content_has_visible_payload([{"type": "image_url", "image_url": {"url": "x"}}])
|
||||
|
||||
def test_list_with_only_empty_text(self):
|
||||
assert not _content_has_visible_payload([{"type": "text", "text": ""}])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP integration — real aiohttp client hitting the adapter handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_adapter() -> APIServerAdapter:
|
||||
return APIServerAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
|
||||
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
|
||||
app = web.Application(middlewares=mws)
|
||||
app["api_server_adapter"] = adapter
|
||||
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
||||
app.router.add_post("/v1/responses", adapter._handle_responses)
|
||||
app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return _make_adapter()
|
||||
|
||||
|
||||
class TestChatCompletionsMultimodalHTTP:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_image_preserved_to_run_agent(self, adapter):
|
||||
"""Multimodal user content reaches _run_agent as a list of parts."""
|
||||
image_payload = [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}},
|
||||
]
|
||||
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(
|
||||
adapter,
|
||||
"_run_agent",
|
||||
new=MagicMock(),
|
||||
) as mock_run:
|
||||
async def _stub(**kwargs):
|
||||
mock_run.captured = kwargs
|
||||
return (
|
||||
{"final_response": "A cat.", "messages": [], "api_calls": 1},
|
||||
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
||||
)
|
||||
mock_run.side_effect = _stub
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": image_payload}],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status == 200, await resp.text()
|
||||
assert mock_run.captured["user_message"] == image_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_only_array_collapses_to_string(self, adapter):
|
||||
"""Text-only array becomes a plain string so logging stays unchanged."""
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new=MagicMock()) as mock_run:
|
||||
async def _stub(**kwargs):
|
||||
mock_run.captured = kwargs
|
||||
return (
|
||||
{"final_response": "ok", "messages": [], "api_calls": 1},
|
||||
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
||||
)
|
||||
mock_run.side_effect = _stub
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello"}]},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status == 200, await resp.text()
|
||||
assert mock_run.captured["user_message"] == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_part_returns_400(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "file", "file": {"file_id": "f_1"}}]},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert body["error"]["code"] == "unsupported_content_type"
|
||||
assert body["error"]["param"] == "messages[0].content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_image_data_url_returns_400(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:text/plain;base64,SGVsbG8="},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert body["error"]["code"] == "unsupported_content_type"
|
||||
|
||||
|
||||
class TestResponsesMultimodalHTTP:
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_image_canonicalized_and_forwarded(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new=MagicMock()) as mock_run:
|
||||
async def _stub(**kwargs):
|
||||
mock_run.captured = kwargs
|
||||
return (
|
||||
{"final_response": "ok", "messages": [], "api_calls": 1},
|
||||
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
||||
)
|
||||
mock_run.side_effect = _stub
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Describe."},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "https://example.com/cat.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status == 200, await resp.text()
|
||||
expected = [
|
||||
{"type": "text", "text": "Describe."},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
|
||||
]
|
||||
assert mock_run.captured["user_message"] == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_file_returns_400(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/v1/responses",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_file", "file_id": "f_1"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert body["error"]["code"] == "unsupported_content_type"
|
||||
95
tests/gateway/test_auto_continue.py
Normal file
95
tests/gateway/test_auto_continue.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for the auto-continue feature (#4493).
|
||||
|
||||
When the gateway restarts mid-agent-work, the session transcript ends on a
|
||||
tool result that the agent never processed. The auto-continue logic detects
|
||||
this and prepends a system note to the next user message so the model
|
||||
finishes the interrupted work before addressing the new input.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _simulate_auto_continue(agent_history: list, user_message: str) -> str:
|
||||
"""Reproduce the auto-continue injection logic from _run_agent().
|
||||
|
||||
This mirrors the exact code in gateway/run.py so we can test the
|
||||
detection and message transformation without spinning up a full
|
||||
gateway runner.
|
||||
"""
|
||||
message = user_message
|
||||
if agent_history and agent_history[-1].get("role") == "tool":
|
||||
message = (
|
||||
"[System note: Your previous turn was interrupted before you could "
|
||||
"process the last tool result(s). The conversation history contains "
|
||||
"tool outputs you haven't responded to yet. Please finish processing "
|
||||
"those results and summarize what was accomplished, then address the "
|
||||
"user's new message below.]\n\n"
|
||||
+ message
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
class TestAutoDetection:
|
||||
"""Test that trailing tool results are correctly detected."""
|
||||
|
||||
def test_trailing_tool_result_triggers_note(self):
|
||||
history = [
|
||||
{"role": "user", "content": "deploy the app"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "deployed successfully"},
|
||||
]
|
||||
result = _simulate_auto_continue(history, "what happened?")
|
||||
assert "[System note:" in result
|
||||
assert "interrupted" in result
|
||||
assert "what happened?" in result
|
||||
|
||||
def test_trailing_assistant_message_no_note(self):
|
||||
history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
result = _simulate_auto_continue(history, "how are you?")
|
||||
assert "[System note:" not in result
|
||||
assert result == "how are you?"
|
||||
|
||||
def test_empty_history_no_note(self):
|
||||
result = _simulate_auto_continue([], "hello")
|
||||
assert result == "hello"
|
||||
|
||||
def test_trailing_user_message_no_note(self):
|
||||
"""Shouldn't happen in practice, but ensure no false positive."""
|
||||
history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
result = _simulate_auto_continue(history, "hello again")
|
||||
assert result == "hello again"
|
||||
|
||||
def test_multiple_tool_results_still_triggers(self):
|
||||
"""Multiple tool calls in a row — last one is still role=tool."""
|
||||
history = [
|
||||
{"role": "user", "content": "search and read"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "search", "arguments": "{}"}},
|
||||
{"id": "call_2", "function": {"name": "read", "arguments": "{}"}},
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "found it"},
|
||||
{"role": "tool", "tool_call_id": "call_2", "content": "file content here"},
|
||||
]
|
||||
result = _simulate_auto_continue(history, "continue")
|
||||
assert "[System note:" in result
|
||||
|
||||
def test_original_message_preserved_after_note(self):
|
||||
"""The user's actual message must appear after the system note."""
|
||||
history = [
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "c1", "function": {"name": "t", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
||||
]
|
||||
result = _simulate_auto_continue(history, "now do X")
|
||||
# System note comes first, then user's message
|
||||
note_end = result.index("]\n\n")
|
||||
user_msg_start = result.index("now do X")
|
||||
assert user_msg_start > note_end
|
||||
@@ -220,6 +220,8 @@ class TestRunBackgroundTask:
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
|
||||
patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.shutdown_memory_provider = MagicMock()
|
||||
mock_agent_instance.close = MagicMock()
|
||||
mock_agent_instance.run_conversation.return_value = mock_result
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
@@ -231,6 +233,37 @@ class TestRunBackgroundTask:
|
||||
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
|
||||
assert "Background task complete" in content
|
||||
assert "Hello from background!" in content
|
||||
mock_agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
mock_agent_instance.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_cleanup_runs_when_background_agent_raises(self):
|
||||
"""Temporary background agents must be cleaned up on error paths too."""
|
||||
runner = _make_runner()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
runner.adapters[Platform.TELEGRAM] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
|
||||
patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.shutdown_memory_provider = MagicMock()
|
||||
mock_agent_instance.close = MagicMock()
|
||||
mock_agent_instance.run_conversation.side_effect = RuntimeError("boom")
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
await runner._run_background_task("say hello", source, "bg_test")
|
||||
|
||||
mock_adapter.send.assert_called_once()
|
||||
mock_agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
mock_agent_instance.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_sends_error_message(self):
|
||||
|
||||
@@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.run import GatewayRunner, _parse_session_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
adapter = SimpleNamespace(send=AsyncMock())
|
||||
adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
|
||||
runner.adapters[Platform.TELEGRAM] = adapter
|
||||
return runner
|
||||
|
||||
@@ -243,3 +243,174 @@ async def test_no_thread_id_sends_no_metadata(monkeypatch, tmp_path):
|
||||
assert adapter.send.await_count == 1
|
||||
_, kwargs = adapter.send.call_args
|
||||
assert kwargs["metadata"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path):
|
||||
from gateway.session import SessionSource
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
|
||||
origin=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-100",
|
||||
chat_type="group",
|
||||
thread_id="42",
|
||||
user_id="123",
|
||||
user_name="Emiliyan",
|
||||
)
|
||||
)
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_watch",
|
||||
"session_key": "agent:main:telegram:group:-100:42",
|
||||
}
|
||||
|
||||
await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
synth_event = adapter.handle_message.await_args.args[0]
|
||||
assert synth_event.internal is True
|
||||
assert synth_event.source.platform == Platform.TELEGRAM
|
||||
assert synth_event.source.chat_id == "-100"
|
||||
assert synth_event.source.chat_type == "group"
|
||||
assert synth_event.source.thread_id == "42"
|
||||
assert synth_event.source.user_id == "123"
|
||||
assert synth_event.source.user_name == "Emiliyan"
|
||||
|
||||
|
||||
def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path):
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_watch",
|
||||
"session_key": "agent:main:telegram:group:-100:42",
|
||||
"platform": "telegram",
|
||||
"chat_id": "-100",
|
||||
"thread_id": "42",
|
||||
"user_id": "123",
|
||||
"user_name": "Emiliyan",
|
||||
}
|
||||
|
||||
source = runner._build_process_event_source(evt)
|
||||
|
||||
assert source is not None
|
||||
assert source.platform == Platform.TELEGRAM
|
||||
assert source.chat_id == "-100"
|
||||
assert source.chat_type == "group"
|
||||
assert source.thread_id == "42"
|
||||
assert source.user_id == "123"
|
||||
assert source.user_name == "Emiliyan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path):
|
||||
"""Negative test: watch notification must NOT route to the foreground thread."""
|
||||
from gateway.session import SessionSource
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
|
||||
# Session store has the process's original thread (thread 42)
|
||||
runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
|
||||
origin=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-100",
|
||||
chat_type="group",
|
||||
thread_id="42",
|
||||
user_id="proc_owner",
|
||||
user_name="alice",
|
||||
)
|
||||
)
|
||||
|
||||
# The evt dict carries the correct session_key — NOT a foreground event
|
||||
evt = {
|
||||
"session_id": "proc_cross_thread",
|
||||
"session_key": "agent:main:telegram:group:-100:42",
|
||||
}
|
||||
|
||||
await runner._inject_watch_notification("[SYSTEM: watch match]", evt)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
synth_event = adapter.handle_message.await_args.args[0]
|
||||
# Must route to thread 42 (process origin), NOT some other thread
|
||||
assert synth_event.source.thread_id == "42"
|
||||
assert synth_event.source.user_id == "proc_owner"
|
||||
|
||||
|
||||
def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path):
|
||||
"""Missing session_key and no platform metadata → None (drop notification)."""
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
|
||||
source = runner._build_process_event_source({"session_id": "proc_orphan"})
|
||||
assert source is None
|
||||
|
||||
|
||||
def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path):
|
||||
"""Invalid platform string → None."""
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_bad",
|
||||
"platform": "not_a_real_platform",
|
||||
"chat_type": "dm",
|
||||
"chat_id": "123",
|
||||
}
|
||||
source = runner._build_process_event_source(evt)
|
||||
assert source is None
|
||||
|
||||
|
||||
def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path):
|
||||
"""Session key with <5 parts doesn't parse, falls through to empty metadata → None."""
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_short",
|
||||
"session_key": "agent:main:telegram", # Too few parts
|
||||
}
|
||||
source = runner._build_process_event_source(evt)
|
||||
assert source is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_session_key helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_session_key_valid():
|
||||
result = _parse_session_key("agent:main:telegram:group:-100")
|
||||
assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "-100"}
|
||||
|
||||
|
||||
def test_parse_session_key_with_extra_parts():
|
||||
"""6th part in a group key may be a user_id, not a thread_id — omit it."""
|
||||
result = _parse_session_key("agent:main:discord:group:chan123:thread456")
|
||||
assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"}
|
||||
|
||||
|
||||
def test_parse_session_key_with_user_id_part():
|
||||
"""Group keys with per-user isolation have user_id as 6th part — don't return as thread_id."""
|
||||
result = _parse_session_key("agent:main:telegram:group:chat1:user99")
|
||||
assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1"}
|
||||
|
||||
|
||||
def test_parse_session_key_dm_with_thread():
|
||||
"""DM keys use parts[5] as thread_id unambiguously."""
|
||||
result = _parse_session_key("agent:main:telegram:dm:chat1:topic42")
|
||||
assert result == {"platform": "telegram", "chat_type": "dm", "chat_id": "chat1", "thread_id": "topic42"}
|
||||
|
||||
|
||||
def test_parse_session_key_thread_chat_type():
|
||||
"""Thread-typed keys use parts[5] as thread_id unambiguously."""
|
||||
result = _parse_session_key("agent:main:discord:thread:chan1:thread99")
|
||||
assert result == {"platform": "discord", "chat_type": "thread", "chat_id": "chan1", "thread_id": "thread99"}
|
||||
|
||||
|
||||
def test_parse_session_key_too_short():
|
||||
assert _parse_session_key("agent:main:telegram") is None
|
||||
assert _parse_session_key("") is None
|
||||
|
||||
|
||||
def test_parse_session_key_wrong_prefix():
|
||||
assert _parse_session_key("cron:main:telegram:dm:123") is None
|
||||
assert _parse_session_key("agent:cron:telegram:dm:123") is None
|
||||
|
||||
@@ -20,11 +20,6 @@ def _make_adapter(monkeypatch, **extra):
|
||||
return BlueBubblesAdapter(cfg)
|
||||
|
||||
|
||||
class TestBlueBubblesPlatformEnum:
|
||||
def test_bluebubbles_enum_exists(self):
|
||||
assert Platform.BLUEBUBBLES.value == "bluebubbles"
|
||||
|
||||
|
||||
class TestBlueBubblesConfigLoading:
|
||||
def test_apply_env_overrides_bluebubbles(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
@@ -41,15 +36,6 @@ class TestBlueBubblesConfigLoading:
|
||||
assert bc.extra["password"] == "secret"
|
||||
assert bc.extra["webhook_port"] == 9999
|
||||
|
||||
def test_connected_platforms_includes_bluebubbles(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
assert Platform.BLUEBUBBLES in config.get_connected_platforms()
|
||||
|
||||
def test_home_channel_set_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
@@ -273,29 +259,6 @@ class TestBlueBubblesGuidResolution:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestBlueBubblesToolsetIntegration:
|
||||
def test_toolset_exists(self):
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
assert "hermes-bluebubbles" in TOOLSETS
|
||||
|
||||
def test_toolset_in_gateway_composite(self):
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
gateway = TOOLSETS["hermes-gateway"]
|
||||
assert "hermes-bluebubbles" in gateway["includes"]
|
||||
|
||||
|
||||
class TestBlueBubblesPromptHint:
|
||||
def test_platform_hint_exists(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
|
||||
assert "bluebubbles" in PLATFORM_HINTS
|
||||
hint = PLATFORM_HINTS["bluebubbles"]
|
||||
assert "iMessage" in hint
|
||||
assert "plain text" in hint
|
||||
|
||||
|
||||
class TestBlueBubblesAttachmentDownload:
|
||||
"""Verify _download_attachment routes to the correct cache helper."""
|
||||
|
||||
|
||||
293
tests/gateway/test_busy_session_ack.py
Normal file
293
tests/gateway/test_busy_session_ack.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Tests for busy-session acknowledgment when user sends messages during active agent runs.
|
||||
|
||||
Verifies that users get an immediate status response instead of total silence
|
||||
when the agent is working on a task. See PR fix for the @Lonely__MH report.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stubs so we can import gateway code without heavy deps
|
||||
# ---------------------------------------------------------------------------
|
||||
import sys, types
|
||||
|
||||
_tg = types.ModuleType("telegram")
|
||||
_tg.constants = types.ModuleType("telegram.constants")
|
||||
_ct = MagicMock()
|
||||
_ct.SUPERGROUP = "supergroup"
|
||||
_ct.GROUP = "group"
|
||||
_ct.PRIVATE = "private"
|
||||
_tg.constants.ChatType = _ct
|
||||
sys.modules.setdefault("telegram", _tg)
|
||||
sys.modules.setdefault("telegram.constants", _tg.constants)
|
||||
sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SessionSource,
|
||||
build_session_key,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_event(text="hello", chat_id="123", platform_val="telegram"):
|
||||
"""Build a minimal MessageEvent."""
|
||||
source = SessionSource(
|
||||
platform=MagicMock(value=platform_val),
|
||||
chat_id=chat_id,
|
||||
chat_type="private",
|
||||
user_id="user1",
|
||||
)
|
||||
evt = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="msg1",
|
||||
)
|
||||
return evt
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Build a minimal GatewayRunner-like object for testing."""
|
||||
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._pending_messages = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._draining = False
|
||||
runner.adapters = {}
|
||||
runner.config = MagicMock()
|
||||
runner.session_store = None
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
return runner, _AGENT_PENDING_SENTINEL
|
||||
|
||||
|
||||
def _make_adapter(platform_val="telegram"):
|
||||
"""Build a minimal adapter mock."""
|
||||
adapter = MagicMock()
|
||||
adapter._pending_messages = {}
|
||||
adapter._send_with_retry = AsyncMock()
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
adapter.platform = MagicMock(value=platform_val)
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBusySessionAck:
|
||||
"""User sends a message while agent is running — should get acknowledgment."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_ack_when_agent_running(self):
|
||||
"""First message during busy session should get a status ack."""
|
||||
runner, sentinel = _make_runner()
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="Are you working?")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
# Simulate running agent
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 21,
|
||||
"max_iterations": 60,
|
||||
"current_tool": "terminal",
|
||||
"last_activity_ts": time.time(),
|
||||
"last_activity_desc": "terminal",
|
||||
"seconds_since_activity": 1.0,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 600 # 10 min ago
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
result = await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
assert result is True # handled
|
||||
# Verify ack was sent
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
|
||||
if not content and call_kwargs.args:
|
||||
# positional args
|
||||
content = str(call_kwargs)
|
||||
assert "Interrupting" in content or "respond" in content
|
||||
assert "/stop" not in content # no need — we ARE interrupting
|
||||
|
||||
# Verify message was queued in adapter pending
|
||||
assert sk in adapter._pending_messages
|
||||
|
||||
# Verify agent interrupt was called
|
||||
agent.interrupt.assert_called_once_with("Are you working?")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debounce_suppresses_rapid_acks(self):
|
||||
"""Second message within 30s should NOT send another ack."""
|
||||
runner, sentinel = _make_runner()
|
||||
adapter = _make_adapter()
|
||||
|
||||
event1 = _make_event(text="hello?")
|
||||
# Reuse the same source so platform mock matches
|
||||
event2 = MessageEvent(
|
||||
text="still there?",
|
||||
message_type=MessageType.TEXT,
|
||||
source=event1.source,
|
||||
message_id="msg2",
|
||||
)
|
||||
sk = build_session_key(event1.source)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 5,
|
||||
"max_iterations": 60,
|
||||
"current_tool": None,
|
||||
"last_activity_ts": time.time(),
|
||||
"last_activity_desc": "api_call",
|
||||
"seconds_since_activity": 0.5,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 60
|
||||
runner.adapters[event1.source.platform] = adapter
|
||||
|
||||
# First message — should get ack
|
||||
result1 = await runner._handle_active_session_busy_message(event1, sk)
|
||||
assert result1 is True
|
||||
assert adapter._send_with_retry.call_count == 1
|
||||
|
||||
# Second message within cooldown — should be queued but no ack
|
||||
result2 = await runner._handle_active_session_busy_message(event2, sk)
|
||||
assert result2 is True
|
||||
assert adapter._send_with_retry.call_count == 1 # still 1, no new ack
|
||||
|
||||
# But interrupt should still be called for both
|
||||
assert agent.interrupt.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_after_cooldown_expires(self):
|
||||
"""After 30s cooldown, a new message should send a fresh ack."""
|
||||
runner, sentinel = _make_runner()
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="hello?")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 10,
|
||||
"max_iterations": 60,
|
||||
"current_tool": "web_search",
|
||||
"last_activity_ts": time.time(),
|
||||
"last_activity_desc": "tool",
|
||||
"seconds_since_activity": 0.5,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 120
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
# First ack
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
assert adapter._send_with_retry.call_count == 1
|
||||
|
||||
# Fake that cooldown expired
|
||||
runner._busy_ack_ts[sk] = time.time() - 31
|
||||
|
||||
# Second ack should go through
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
assert adapter._send_with_retry.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_status_detail(self):
|
||||
"""Ack message should include iteration and tool info when available."""
|
||||
runner, sentinel = _make_runner()
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="yo")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"api_call_count": 21,
|
||||
"max_iterations": 60,
|
||||
"current_tool": "terminal",
|
||||
"last_activity_ts": time.time(),
|
||||
"last_activity_desc": "terminal",
|
||||
"seconds_since_activity": 0.5,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time() - 600 # 10 min
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
await runner._handle_active_session_busy_message(event, sk)
|
||||
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content", "")
|
||||
assert "21/60" in content # iteration
|
||||
assert "terminal" in content # current tool
|
||||
assert "10 min" in content # elapsed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draining_still_works(self):
|
||||
"""Draining case should still produce the drain-specific message."""
|
||||
runner, sentinel = _make_runner()
|
||||
runner._draining = True
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="hello")
|
||||
sk = build_session_key(event.source)
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
# Mock the drain-specific methods
|
||||
runner._queue_during_drain_enabled = lambda: False
|
||||
runner._status_action_gerund = lambda: "restarting"
|
||||
|
||||
result = await runner._handle_active_session_busy_message(event, sk)
|
||||
assert result is True
|
||||
|
||||
call_kwargs = adapter._send_with_retry.call_args
|
||||
content = call_kwargs.kwargs.get("content", "")
|
||||
assert "restarting" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_sentinel_no_interrupt(self):
|
||||
"""When agent is PENDING_SENTINEL, don't call interrupt (it has no method)."""
|
||||
runner, sentinel = _make_runner()
|
||||
adapter = _make_adapter()
|
||||
|
||||
event = _make_event(text="hey")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
runner._running_agents[sk] = sentinel
|
||||
runner._running_agents_ts[sk] = time.time()
|
||||
runner.adapters[event.source.platform] = adapter
|
||||
|
||||
result = await runner._handle_active_session_busy_message(event, sk)
|
||||
assert result is True
|
||||
# Should still send ack
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_adapter_falls_through(self):
|
||||
"""If adapter is missing, return False so default path handles it."""
|
||||
runner, sentinel = _make_runner()
|
||||
|
||||
event = _make_event(text="hello")
|
||||
sk = build_session_key(event.source)
|
||||
|
||||
# No adapter registered
|
||||
runner._running_agents[sk] = MagicMock()
|
||||
|
||||
result = await runner._handle_active_session_busy_message(event, sk)
|
||||
assert result is False # not handled, let default path try
|
||||
148
tests/gateway/test_cancel_background_drain.py
Normal file
148
tests/gateway/test_cancel_background_drain.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Regression test: cancel_background_tasks must drain late-arrival tasks.
|
||||
|
||||
During gateway shutdown, a message arriving while
|
||||
cancel_background_tasks is mid-await can spawn a fresh
|
||||
_process_message_background task via handle_message, which is added
|
||||
to self._background_tasks. Without the re-drain loop, the subsequent
|
||||
_background_tasks.clear() drops the reference; the task runs
|
||||
untracked against a disconnecting adapter.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
class _StubAdapter(BasePlatformAdapter):
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, text, **kwargs):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {}
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = _StubAdapter(PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM)
|
||||
adapter._send_with_retry = AsyncMock(return_value=None)
|
||||
return adapter
|
||||
|
||||
|
||||
def _event(text, cid="42"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.TELEGRAM, chat_id=cid, chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_drains_late_arrivals():
|
||||
"""A message that arrives during the gather window must be picked
|
||||
up by the re-drain loop, not leaked as an untracked task."""
|
||||
adapter = _make_adapter()
|
||||
sk = build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="42", chat_type="dm")
|
||||
)
|
||||
|
||||
m1_started = asyncio.Event()
|
||||
m1_cleanup_running = asyncio.Event()
|
||||
m2_started = asyncio.Event()
|
||||
m2_cancelled = asyncio.Event()
|
||||
|
||||
async def handler(event):
|
||||
if event.text == "M1":
|
||||
m1_started.set()
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
m1_cleanup_running.set()
|
||||
# Widen the gather window with a shielded cleanup
|
||||
# delay so M2 can get injected during it.
|
||||
await asyncio.shield(asyncio.sleep(0.2))
|
||||
raise
|
||||
else: # M2 — the late arrival
|
||||
m2_started.set()
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
m2_cancelled.set()
|
||||
raise
|
||||
|
||||
adapter._message_handler = handler
|
||||
|
||||
# Spawn M1.
|
||||
await adapter.handle_message(_event("M1"))
|
||||
await asyncio.wait_for(m1_started.wait(), timeout=1.0)
|
||||
|
||||
# Kick off shutdown. This will cancel M1 and await its cleanup.
|
||||
cancel_task = asyncio.create_task(adapter.cancel_background_tasks())
|
||||
|
||||
# Wait until M1's cleanup is running (inside the shielded sleep).
|
||||
# This is the race window: cancel_task is awaiting gather, M1 is
|
||||
# shielded in cleanup, the _active_sessions entry has been cleared
|
||||
# by M1's own finally.
|
||||
await asyncio.wait_for(m1_cleanup_running.wait(), timeout=1.0)
|
||||
|
||||
# Clear the active-session entry (M1's finally hasn't fully run yet,
|
||||
# but in production the platform dispatcher would deliver a new
|
||||
# message that takes the no-active-session spawn path). For this
|
||||
# repro, make it deterministic.
|
||||
adapter._active_sessions.pop(sk, None)
|
||||
|
||||
# Inject late arrival — spawns a fresh _process_message_background
|
||||
# task and adds it to _background_tasks while cancel_task is still
|
||||
# in gather.
|
||||
await adapter.handle_message(_event("M2"))
|
||||
await asyncio.wait_for(m2_started.wait(), timeout=1.0)
|
||||
|
||||
# Let cancel_task finish. Round 1's gather completes when M1's
|
||||
# shielded cleanup finishes. Round 2 should pick up M2.
|
||||
await asyncio.wait_for(cancel_task, timeout=5.0)
|
||||
|
||||
# Assert M2 was drained, not leaked.
|
||||
assert m2_cancelled.is_set(), (
|
||||
"Late-arrival M2 was NOT cancelled by cancel_background_tasks — "
|
||||
"the re-drain loop is missing and the task leaked"
|
||||
)
|
||||
assert adapter._background_tasks == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_handles_no_tasks():
|
||||
"""Regression guard: no tasks, no hang, no error."""
|
||||
adapter = _make_adapter()
|
||||
await adapter.cancel_background_tasks()
|
||||
assert adapter._background_tasks == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_bounded_rounds():
|
||||
"""Regression guard: the drain loop is bounded — it does not spin
|
||||
forever even if late-arrival tasks keep getting spawned."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
# Single well-behaved task that cancels cleanly — baseline check
|
||||
# that the loop terminates in one round.
|
||||
async def quick():
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(quick())
|
||||
adapter._background_tasks.add(task)
|
||||
|
||||
await adapter.cancel_background_tasks()
|
||||
assert task.done()
|
||||
assert adapter._background_tasks == set()
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
from gateway.channel_directory import (
|
||||
build_channel_directory,
|
||||
lookup_channel_type,
|
||||
resolve_channel_name,
|
||||
format_directory_for_display,
|
||||
load_directory,
|
||||
@@ -285,3 +286,49 @@ class TestFormatDirectoryForDisplay:
|
||||
assert "Discord (Server1):" in result
|
||||
assert "Discord (Server2):" in result
|
||||
assert "discord:#general" in result
|
||||
|
||||
|
||||
class TestLookupChannelType:
|
||||
def _setup(self, tmp_path, platforms):
|
||||
cache_file = _write_directory(tmp_path, platforms)
|
||||
return patch("gateway.channel_directory.DIRECTORY_PATH", cache_file)
|
||||
|
||||
def test_forum_channel(self, tmp_path):
|
||||
platforms = {
|
||||
"discord": [
|
||||
{"id": "100", "name": "ideas", "guild": "Server1", "type": "forum"},
|
||||
]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert lookup_channel_type("discord", "100") == "forum"
|
||||
|
||||
def test_regular_channel(self, tmp_path):
|
||||
platforms = {
|
||||
"discord": [
|
||||
{"id": "200", "name": "general", "guild": "Server1", "type": "channel"},
|
||||
]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert lookup_channel_type("discord", "200") == "channel"
|
||||
|
||||
def test_unknown_chat_id_returns_none(self, tmp_path):
|
||||
platforms = {
|
||||
"discord": [
|
||||
{"id": "200", "name": "general", "guild": "Server1", "type": "channel"},
|
||||
]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert lookup_channel_type("discord", "999") is None
|
||||
|
||||
def test_unknown_platform_returns_none(self, tmp_path):
|
||||
with self._setup(tmp_path, {}):
|
||||
assert lookup_channel_type("discord", "100") is None
|
||||
|
||||
def test_channel_without_type_key_returns_none(self, tmp_path):
|
||||
platforms = {
|
||||
"discord": [
|
||||
{"id": "300", "name": "general", "guild": "Server1"},
|
||||
]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert lookup_channel_type("discord", "300") is None
|
||||
|
||||
@@ -160,6 +160,30 @@ class TestCommandBypassActiveSession:
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:status" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_bypasses_guard(self):
|
||||
"""/agents must bypass so active-task queries don't interrupt runs."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/agents"))
|
||||
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:agents" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_alias_bypasses_guard(self):
|
||||
"""/tasks alias must bypass active-session guard too."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/tasks"))
|
||||
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:tasks" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_bypasses_guard(self):
|
||||
"""/background must bypass so it spawns a parallel task, not an interrupt."""
|
||||
@@ -176,6 +200,149 @@ class TestCommandBypassActiveSession:
|
||||
"/background response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steer_bypasses_guard(self):
|
||||
"""/steer must bypass the Level-1 active-session guard so it reaches
|
||||
the gateway runner's /steer handler and injects into the running
|
||||
agent instead of being queued as user text for the next turn.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/steer also check auth.log"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/steer was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:steer" in r for r in adapter.sent_responses), (
|
||||
"/steer response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_bypasses_guard(self):
|
||||
"""/help must bypass so it is not silently dropped as pending slash text."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/help"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/help was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:help" in r for r in adapter.sent_responses), (
|
||||
"/help response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_bypasses_guard(self):
|
||||
"""/update must bypass so it is not discarded by the pending-command safety net."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/update"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/update was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:update" in r for r in adapter.sent_responses), (
|
||||
"/update response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_bypasses_guard(self):
|
||||
"""/queue must bypass so it can queue without interrupting."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/queue follow up"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/queue was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:queue" in r for r in adapter.sent_responses), (
|
||||
"/queue response was not sent back to the user"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass
|
||||
# instead of interrupting + being discarded. Regression for the Discord
|
||||
# ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title,
|
||||
# /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp,
|
||||
# /sethome, /reset silently interrupted the running agent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllResolvableCommandsBypassGuard:
|
||||
"""Every recognized slash command must bypass the Level-1 active-session
|
||||
guard. Without this, commands the user fires mid-run interrupt the agent
|
||||
AND get silently discarded by the slash-command safety net (zero-char
|
||||
response)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_text,canonical",
|
||||
[
|
||||
("/model claude-sonnet-4", "model"),
|
||||
("/model", "model"),
|
||||
("/reasoning high", "reasoning"),
|
||||
("/personality default", "personality"),
|
||||
("/voice on", "voice"),
|
||||
("/insights 7", "insights"),
|
||||
("/title my session", "title"),
|
||||
("/resume yesterday", "resume"),
|
||||
("/retry", "retry"),
|
||||
("/undo", "undo"),
|
||||
("/compress", "compress"),
|
||||
("/usage", "usage"),
|
||||
("/provider", "provider"),
|
||||
("/reload-mcp", "reload-mcp"),
|
||||
("/sethome", "sethome"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_bypasses_guard(self, command_text, canonical):
|
||||
"""Any resolvable slash command bypasses instead of being queued."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event(command_text))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
f"{command_text} was queued as pending — it should bypass the guard"
|
||||
)
|
||||
assert len(adapter.sent_responses) > 0, (
|
||||
f"{command_text} produced no response — it should be dispatched, "
|
||||
"not silently discarded"
|
||||
)
|
||||
|
||||
def test_should_bypass_returns_true_for_every_registered_command(self):
|
||||
"""Spot-check: the commands previously-broken on Discord all bypass."""
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
|
||||
for cmd in (
|
||||
"model", "reasoning", "personality", "voice", "insights", "title",
|
||||
"resume", "retry", "undo", "compress", "usage", "provider",
|
||||
"reload-mcp", "sethome", "reset",
|
||||
):
|
||||
assert should_bypass_active_session(cmd) is True, (
|
||||
f"/{cmd} must bypass the active-session guard"
|
||||
)
|
||||
|
||||
def test_should_bypass_returns_false_for_unknown(self):
|
||||
"""Unknown words don't bypass — they get queued as user text."""
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
|
||||
assert should_bypass_active_session("foobar") is False
|
||||
assert should_bypass_active_session(None) is False
|
||||
assert should_bypass_active_session("") is False
|
||||
# A file path split on whitespace: '/path/to/file.py' -> 'path/to/file.py'
|
||||
assert should_bypass_active_session("path/to/file.py") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass messages still get queued
|
||||
|
||||
@@ -62,6 +62,8 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
history = _make_history()
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
@@ -83,6 +85,8 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
assert "No changes from compression" in result
|
||||
assert "Compressed:" not in result
|
||||
assert "Rough transcript estimate: ~100 tokens (unchanged)" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -95,6 +99,8 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
]
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
@@ -119,3 +125,5 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
assert "Compressed: 4 → 3 messages" in result
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in result
|
||||
assert "denser summaries" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
@@ -71,6 +71,51 @@ class TestGetConnectedPlatforms:
|
||||
config = GatewayConfig()
|
||||
assert config.get_connected_platforms() == []
|
||||
|
||||
def test_dingtalk_recognised_via_extras(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DINGTALK: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"client_id": "cid", "client_secret": "sec"},
|
||||
),
|
||||
},
|
||||
)
|
||||
assert Platform.DINGTALK in config.get_connected_platforms()
|
||||
|
||||
def test_dingtalk_recognised_via_env_vars(self, monkeypatch):
|
||||
"""DingTalk configured via env vars (no extras) should still be
|
||||
recognised as connected — covers the case where _apply_env_overrides
|
||||
hasn't populated extras yet."""
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_cid")
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_sec")
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
|
||||
},
|
||||
)
|
||||
assert Platform.DINGTALK in config.get_connected_platforms()
|
||||
|
||||
def test_dingtalk_missing_creds_not_connected(self, monkeypatch):
|
||||
monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
|
||||
},
|
||||
)
|
||||
assert Platform.DINGTALK not in config.get_connected_platforms()
|
||||
|
||||
def test_dingtalk_disabled_not_connected(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DINGTALK: PlatformConfig(
|
||||
enabled=False,
|
||||
extra={"client_id": "cid", "client_secret": "sec"},
|
||||
),
|
||||
},
|
||||
)
|
||||
assert Platform.DINGTALK not in config.get_connected_platforms()
|
||||
|
||||
|
||||
class TestSessionResetPolicy:
|
||||
def test_roundtrip(self):
|
||||
@@ -193,6 +238,67 @@ class TestLoadGatewayConfig:
|
||||
|
||||
assert config.thread_sessions_per_user is False
|
||||
|
||||
def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"discord:\n"
|
||||
" channel_prompts:\n"
|
||||
" \"123\": Research mode\n"
|
||||
" 456: Therapist mode\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.DISCORD].extra["channel_prompts"] == {
|
||||
"123": "Research mode",
|
||||
"456": "Therapist mode",
|
||||
}
|
||||
|
||||
def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"telegram:\n"
|
||||
" channel_prompts:\n"
|
||||
' "-1001234567": Research assistant\n'
|
||||
" 789: Creative writing\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.TELEGRAM].extra["channel_prompts"] == {
|
||||
"-1001234567": "Research assistant",
|
||||
"789": "Creative writing",
|
||||
}
|
||||
|
||||
def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"slack:\n"
|
||||
" channel_prompts:\n"
|
||||
' "C01ABC": Code review mode\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.SLACK].extra["channel_prompts"] == {
|
||||
"C01ABC": "Code review mode",
|
||||
}
|
||||
|
||||
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
@@ -223,6 +329,58 @@ class TestLoadGatewayConfig:
|
||||
assert config.unauthorized_dm_behavior == "ignore"
|
||||
assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair"
|
||||
|
||||
def test_bridges_telegram_disable_link_previews_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"telegram:\n"
|
||||
" disable_link_previews: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.TELEGRAM].extra["disable_link_previews"] is True
|
||||
|
||||
def test_bridges_telegram_proxy_url_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"telegram:\n"
|
||||
" proxy_url: socks5://127.0.0.1:1080\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_PROXY", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
import os
|
||||
assert os.environ.get("TELEGRAM_PROXY") == "socks5://127.0.0.1:1080"
|
||||
|
||||
def test_telegram_proxy_env_takes_precedence_over_config(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"telegram:\n"
|
||||
" proxy_url: http://from-config:8080\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("TELEGRAM_PROXY", "socks5://from-env:1080")
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
import os
|
||||
assert os.environ.get("TELEGRAM_PROXY") == "socks5://from-env:1080"
|
||||
|
||||
|
||||
class TestHomeChannelEnvOverrides:
|
||||
"""Home channel env vars should apply even when the platform was already
|
||||
|
||||
@@ -37,6 +37,10 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
||||
for cfg_key, env_var in terminal_env_map.items():
|
||||
if cfg_key in terminal_cfg:
|
||||
val = terminal_cfg[cfg_key]
|
||||
# Skip cwd placeholder values — don't overwrite already-resolved
|
||||
# TERMINAL_CWD. Mirrors the fix in gateway/run.py.
|
||||
if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"):
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
env[env_var] = json.dumps(val)
|
||||
else:
|
||||
@@ -146,3 +150,58 @@ class TestTopLevelCwdAlias:
|
||||
cfg = {"cwd": "/from/config"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/config"
|
||||
|
||||
|
||||
class TestNestedTerminalCwdPlaceholderSkip:
|
||||
"""terminal.cwd placeholder values must not clobber TERMINAL_CWD.
|
||||
|
||||
When config.yaml has terminal.cwd: "." (or "auto"/"cwd"), the gateway
|
||||
config bridge should NOT write that placeholder to TERMINAL_CWD.
|
||||
This prevents .env or MESSAGING_CWD values from being overwritten.
|
||||
See issues #10225, #4672, #10817.
|
||||
"""
|
||||
|
||||
def test_terminal_dot_cwd_does_not_clobber_env(self):
|
||||
"""terminal.cwd: '.' should not overwrite a pre-set TERMINAL_CWD."""
|
||||
cfg = {"terminal": {"cwd": "."}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_auto_cwd_does_not_clobber_env(self):
|
||||
cfg = {"terminal": {"cwd": "auto"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_cwd_keyword_does_not_clobber_env(self):
|
||||
cfg = {"terminal": {"cwd": "cwd"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_explicit_cwd_does_override(self):
|
||||
"""terminal.cwd: '/explicit/path' SHOULD override TERMINAL_CWD."""
|
||||
cfg = {"terminal": {"cwd": "/explicit/path"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/old/value"})
|
||||
assert result["TERMINAL_CWD"] == "/explicit/path"
|
||||
|
||||
def test_terminal_dot_cwd_falls_back_to_messaging_cwd(self):
|
||||
"""terminal.cwd: '.' with no TERMINAL_CWD should fall to MESSAGING_CWD."""
|
||||
cfg = {"terminal": {"cwd": "."}}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/env"
|
||||
|
||||
def test_terminal_dot_cwd_and_messaging_cwd_both_set(self):
|
||||
"""Pre-set TERMINAL_CWD from .env wins over terminal.cwd: '.'."""
|
||||
cfg = {"terminal": {"cwd": ".", "backend": "local"}}
|
||||
result = _simulate_config_bridge(cfg, {
|
||||
"TERMINAL_CWD": "/my/project",
|
||||
"MESSAGING_CWD": "/fallback",
|
||||
})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_non_cwd_terminal_keys_still_bridge(self):
|
||||
"""Other terminal config keys (backend, timeout) should still bridge normally."""
|
||||
cfg = {"terminal": {"cwd": ".", "backend": "docker", "timeout": "300"}}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
assert result["TERMINAL_TIMEOUT"] == "300"
|
||||
assert result["TERMINAL_CWD"] == "/from/env"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
@@ -197,7 +198,7 @@ class TestSend:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
adapter._http_client = mock_client
|
||||
adapter._session_webhooks["chat-123"] = "https://cached.example/webhook"
|
||||
adapter._session_webhooks["chat-123"] = ("https://cached.example/webhook", 9999999999999)
|
||||
|
||||
result = await adapter.send("chat-123", "Hello!")
|
||||
assert result.success is True
|
||||
@@ -230,6 +231,29 @@ class TestSend:
|
||||
|
||||
class TestConnect:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_closes_session_websocket(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
websocket = AsyncMock()
|
||||
blocker = asyncio.Event()
|
||||
|
||||
async def _run_forever():
|
||||
try:
|
||||
await blocker.wait()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
adapter._stream_client = SimpleNamespace(websocket=websocket)
|
||||
adapter._stream_task = asyncio.create_task(_run_forever())
|
||||
adapter._running = True
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
websocket.close.assert_awaited_once()
|
||||
assert adapter._stream_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_without_sdk(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
@@ -269,7 +293,678 @@ class TestConnect:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformEnum:
|
||||
# ---------------------------------------------------------------------------
|
||||
# SDK compatibility regression tests (dingtalk-stream >= 0.20 / 0.24)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWebhookDomainAllowlist:
|
||||
"""Guard the webhook origin allowlist against regression.
|
||||
|
||||
The SDK started returning reply webhooks on ``oapi.dingtalk.com`` in
|
||||
addition to ``api.dingtalk.com``. Both must be accepted, and hostile
|
||||
lookalikes must still be rejected (SSRF defence-in-depth).
|
||||
"""
|
||||
|
||||
def test_api_domain_accepted(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
assert _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://api.dingtalk.com/robot/send?access_token=x"
|
||||
)
|
||||
|
||||
def test_oapi_domain_accepted(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
assert _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://oapi.dingtalk.com/robot/send?access_token=x"
|
||||
)
|
||||
|
||||
def test_http_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
assert not _DINGTALK_WEBHOOK_RE.match("http://api.dingtalk.com/robot/send")
|
||||
|
||||
def test_suffix_attack_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
assert not _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://api.dingtalk.com.evil.example/"
|
||||
)
|
||||
|
||||
def test_unsanctioned_subdomain_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
# Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through
|
||||
assert not _DINGTALK_WEBHOOK_RE.match("https://eapi.dingtalk.com/robot/send")
|
||||
|
||||
|
||||
class TestHandlerProcessIsAsync:
|
||||
"""dingtalk-stream >= 0.20 requires ``process`` to be a coroutine."""
|
||||
|
||||
def test_process_is_coroutine_function(self):
|
||||
from gateway.platforms.dingtalk import _IncomingHandler
|
||||
assert asyncio.iscoroutinefunction(_IncomingHandler.process)
|
||||
|
||||
|
||||
class TestExtractText:
|
||||
"""_extract_text must handle both legacy and current SDK payload shapes.
|
||||
|
||||
Before SDK 0.20 ``message.text`` was a ``dict`` with a ``content`` key.
|
||||
From 0.20 onward it is a ``TextContent`` dataclass whose ``__str__``
|
||||
returns ``"TextContent(content=...)"`` — falling back to ``str(text)``
|
||||
leaks that repr into the agent's input.
|
||||
"""
|
||||
|
||||
def test_text_as_dict_legacy(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = {"content": "hello world"}
|
||||
msg.rich_text_content = None
|
||||
msg.rich_text = None
|
||||
assert DingTalkAdapter._extract_text(msg) == "hello world"
|
||||
|
||||
def test_text_as_textcontent_object(self):
|
||||
"""SDK >= 0.20 shape: object with ``.content`` attribute."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
|
||||
class FakeTextContent:
|
||||
content = "hello from new sdk"
|
||||
|
||||
def __str__(self): # mimic real SDK repr
|
||||
return f"TextContent(content={self.content})"
|
||||
|
||||
msg = MagicMock()
|
||||
msg.text = FakeTextContent()
|
||||
msg.rich_text_content = None
|
||||
msg.rich_text = None
|
||||
result = DingTalkAdapter._extract_text(msg)
|
||||
assert result == "hello from new sdk"
|
||||
assert "TextContent(" not in result
|
||||
|
||||
def test_text_content_attr_with_empty_string(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
|
||||
class FakeTextContent:
|
||||
content = ""
|
||||
|
||||
msg = MagicMock()
|
||||
msg.text = FakeTextContent()
|
||||
msg.rich_text_content = None
|
||||
msg.rich_text = None
|
||||
assert DingTalkAdapter._extract_text(msg) == ""
|
||||
|
||||
def test_rich_text_content_new_shape(self):
|
||||
"""SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
|
||||
class FakeRichText:
|
||||
rich_text_list = [{"text": "hello "}, {"text": "world"}]
|
||||
|
||||
msg = MagicMock()
|
||||
msg.text = None
|
||||
msg.rich_text_content = FakeRichText()
|
||||
msg.rich_text = None
|
||||
result = DingTalkAdapter._extract_text(msg)
|
||||
assert "hello" in result and "world" in result
|
||||
|
||||
def test_rich_text_legacy_shape(self):
|
||||
"""Legacy ``message.rich_text`` list remains supported."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = None
|
||||
msg.rich_text_content = None
|
||||
msg.rich_text = [{"text": "legacy "}, {"text": "rich"}]
|
||||
result = DingTalkAdapter._extract_text(msg)
|
||||
assert "legacy" in result and "rich" in result
|
||||
|
||||
def test_empty_message(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = None
|
||||
msg.rich_text_content = None
|
||||
msg.rich_text = None
|
||||
assert DingTalkAdapter._extract_text(msg) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group gating — require_mention + allowed_users (parity with other platforms)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_gating_adapter(monkeypatch, *, extra=None, env=None):
|
||||
"""Build a DingTalkAdapter with only the gating fields populated.
|
||||
|
||||
Clears every DINGTALK_* gating env var before applying the caller's
|
||||
overrides so individual tests stay isolated.
|
||||
"""
|
||||
for key in (
|
||||
"DINGTALK_REQUIRE_MENTION",
|
||||
"DINGTALK_MENTION_PATTERNS",
|
||||
"DINGTALK_FREE_RESPONSE_CHATS",
|
||||
"DINGTALK_ALLOWED_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in (env or {}).items():
|
||||
monkeypatch.setenv(key, value)
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))
|
||||
|
||||
|
||||
class TestAllowedUsersGate:
|
||||
|
||||
def test_empty_allowlist_allows_everyone(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(monkeypatch)
|
||||
assert adapter._is_user_allowed("anyone", "any-staff") is True
|
||||
|
||||
def test_wildcard_allowlist_allows_everyone(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(monkeypatch, extra={"allowed_users": ["*"]})
|
||||
assert adapter._is_user_allowed("anyone", "any-staff") is True
|
||||
|
||||
def test_matches_sender_id_case_insensitive(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"allowed_users": ["SenderABC"]}
|
||||
)
|
||||
assert adapter._is_user_allowed("senderabc", "") is True
|
||||
|
||||
def test_matches_staff_id(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"allowed_users": ["staff_1234"]}
|
||||
)
|
||||
assert adapter._is_user_allowed("", "staff_1234") is True
|
||||
|
||||
def test_rejects_unknown_user(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"allowed_users": ["staff_1234"]}
|
||||
)
|
||||
assert adapter._is_user_allowed("other-sender", "other-staff") is False
|
||||
|
||||
def test_env_var_csv_populates_allowlist(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, env={"DINGTALK_ALLOWED_USERS": "alice,bob,carol"}
|
||||
)
|
||||
assert adapter._is_user_allowed("alice", "") is True
|
||||
assert adapter._is_user_allowed("dave", "") is False
|
||||
|
||||
|
||||
class TestMentionPatterns:
|
||||
|
||||
def test_empty_patterns_list(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(monkeypatch)
|
||||
assert adapter._mention_patterns == []
|
||||
assert adapter._message_matches_mention_patterns("anything") is False
|
||||
|
||||
def test_pattern_matches_text(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"mention_patterns": ["^hermes"]}
|
||||
)
|
||||
assert adapter._message_matches_mention_patterns("hermes please help") is True
|
||||
assert adapter._message_matches_mention_patterns("please hermes help") is False
|
||||
|
||||
def test_pattern_is_case_insensitive(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"mention_patterns": ["^hermes"]}
|
||||
)
|
||||
assert adapter._message_matches_mention_patterns("HERMES help") is True
|
||||
|
||||
def test_invalid_regex_is_skipped_not_raised(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch,
|
||||
extra={"mention_patterns": ["[unclosed", "^valid"]},
|
||||
)
|
||||
# Invalid pattern dropped, valid one kept
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("valid trigger") is True
|
||||
|
||||
def test_env_var_json_populates_patterns(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch,
|
||||
env={"DINGTALK_MENTION_PATTERNS": '["^bot", "^assistant"]'},
|
||||
)
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
assert adapter._message_matches_mention_patterns("bot ping") is True
|
||||
|
||||
def test_env_var_newline_fallback_when_not_json(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch,
|
||||
env={"DINGTALK_MENTION_PATTERNS": "^bot\n^assistant"},
|
||||
)
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
|
||||
|
||||
class TestShouldProcessMessage:
|
||||
|
||||
def test_dm_always_accepted(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"require_mention": True}
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=False)
|
||||
assert adapter._should_process_message(msg, "hi", is_group=False, chat_id="dm1") is True
|
||||
|
||||
def test_group_rejected_when_require_mention_and_no_trigger(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"require_mention": True}
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=False)
|
||||
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is False
|
||||
|
||||
def test_group_accepted_when_require_mention_disabled(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"require_mention": False}
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=False)
|
||||
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
|
||||
|
||||
def test_group_accepted_when_bot_is_mentioned(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch, extra={"require_mention": True}
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=True)
|
||||
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
|
||||
|
||||
def test_group_accepted_when_text_matches_wake_word(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": ["^hermes"]},
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=False)
|
||||
assert adapter._should_process_message(msg, "hermes help", is_group=True, chat_id="grp1") is True
|
||||
|
||||
def test_group_accepted_when_chat_in_free_response_list(self, monkeypatch):
|
||||
adapter = _make_gating_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "free_response_chats": ["grp1"]},
|
||||
)
|
||||
msg = MagicMock(is_in_at_list=False)
|
||||
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp1") is True
|
||||
# Different group still blocked
|
||||
assert adapter._should_process_message(msg, "hi", is_group=True, chat_id="grp2") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _IncomingHandler.process — session_webhook extraction & fire-and-forget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIncomingHandlerProcess:
|
||||
"""Verify that _IncomingHandler.process correctly converts callback data
|
||||
and dispatches message processing as a background task (fire-and-forget)
|
||||
so the SDK ACK is returned immediately."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_extracts_session_webhook(self):
|
||||
"""session_webhook must be populated from callback data."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
|
||||
|
||||
callback = MagicMock()
|
||||
callback.data = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hello"},
|
||||
"senderId": "user1",
|
||||
"conversationId": "conv1",
|
||||
"sessionWebhook": "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
|
||||
"msgId": "msg-001",
|
||||
}
|
||||
|
||||
result = await handler.process(callback)
|
||||
# Should return ACK immediately (STATUS_OK = 200)
|
||||
assert result[0] == 200
|
||||
|
||||
# Let the background task run
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# _on_message should have been called with a ChatbotMessage
|
||||
adapter._on_message.assert_called_once()
|
||||
chatbot_msg = adapter._on_message.call_args[0][0]
|
||||
assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_fallback_session_webhook_when_from_dict_misses_it(self):
|
||||
"""If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK
|
||||
version mismatch), the handler should fall back to extracting it
|
||||
directly from the raw data dict."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
|
||||
|
||||
callback = MagicMock()
|
||||
# Use a key that from_dict might not recognise in some SDK versions
|
||||
callback.data = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hi"},
|
||||
"senderId": "user2",
|
||||
"conversationId": "conv2",
|
||||
"session_webhook": "https://oapi.dingtalk.com/robot/sendBySession?session=def",
|
||||
"msgId": "msg-002",
|
||||
}
|
||||
|
||||
await handler.process(callback)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
adapter._on_message.assert_called_once()
|
||||
chatbot_msg = adapter._on_message.call_args[0][0]
|
||||
assert chatbot_msg.session_webhook == "https://oapi.dingtalk.com/robot/sendBySession?session=def"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_returns_ack_immediately(self):
|
||||
"""process() must not block on _on_message — it should return
|
||||
the ACK tuple before the message is fully processed."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
processing_started = asyncio.Event()
|
||||
processing_gate = asyncio.Event()
|
||||
|
||||
async def slow_on_message(msg):
|
||||
processing_started.set()
|
||||
await processing_gate.wait() # Block until we release
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = slow_on_message
|
||||
handler = _IncomingHandler(adapter, asyncio.get_running_loop())
|
||||
|
||||
callback = MagicMock()
|
||||
callback.data = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": "test"},
|
||||
"senderId": "u",
|
||||
"conversationId": "c",
|
||||
"sessionWebhook": "https://oapi.dingtalk.com/x",
|
||||
"msgId": "m",
|
||||
}
|
||||
|
||||
# process() should return immediately even though _on_message blocks
|
||||
result = await handler.process(callback)
|
||||
assert result[0] == 200
|
||||
|
||||
# Clean up: release the gate so the background task finishes
|
||||
processing_gate.set()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text extraction — mention preservation + platform sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractTextMentions:
|
||||
|
||||
def test_preserves_at_mentions_in_text(self):
|
||||
"""@mentions are routing signals (via isInAtList), not text to strip.
|
||||
|
||||
Stripping all @handles collateral-damages emails, SSH URLs, and
|
||||
literal references the user wrote.
|
||||
"""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
cases = [
|
||||
("@bot hello", "@bot hello"),
|
||||
("contact alice@example.com", "contact alice@example.com"),
|
||||
("git@github.com:foo/bar.git", "git@github.com:foo/bar.git"),
|
||||
("what does @openai think", "what does @openai think"),
|
||||
("@机器人 转发给 @老王", "@机器人 转发给 @老王"),
|
||||
]
|
||||
for text, expected in cases:
|
||||
msg = MagicMock()
|
||||
msg.text = text
|
||||
msg.rich_text = None
|
||||
msg.rich_text_content = None
|
||||
assert DingTalkAdapter._extract_text(msg) == expected, (
|
||||
f"mangled: {text!r} -> {DingTalkAdapter._extract_text(msg)!r}"
|
||||
)
|
||||
|
||||
def test_dingtalk_in_platform_enum(self):
|
||||
assert Platform.DINGTALK.value == "dingtalk"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrency — chat-scoped message context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMessageContextIsolation:
|
||||
|
||||
def test_contexts_keyed_by_chat_id(self):
|
||||
"""Two concurrent chats must not clobber each other's context."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
|
||||
msg_b = MagicMock(conversation_id="chat-B", sender_staff_id="user-B")
|
||||
adapter._message_contexts["chat-A"] = msg_a
|
||||
adapter._message_contexts["chat-B"] = msg_b
|
||||
|
||||
assert adapter._message_contexts["chat-A"] is msg_a
|
||||
assert adapter._message_contexts["chat-B"] is msg_b
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Card lifecycle: finalize via metadata["streaming"]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCardLifecycle:
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_with_card(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
a = DingTalkAdapter(PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"card_template_id": "tmpl-1"},
|
||||
))
|
||||
a._card_sdk = MagicMock()
|
||||
a._card_sdk.create_card_with_options_async = AsyncMock()
|
||||
a._card_sdk.deliver_card_with_options_async = AsyncMock()
|
||||
a._card_sdk.streaming_update_with_options_async = AsyncMock()
|
||||
a._http_client = AsyncMock()
|
||||
a._get_access_token = AsyncMock(return_value="token")
|
||||
# Minimal message context
|
||||
msg = MagicMock(
|
||||
conversation_id="chat-1",
|
||||
conversation_type="1",
|
||||
sender_staff_id="staff-1",
|
||||
message_id="user-msg-1",
|
||||
)
|
||||
a._message_contexts["chat-1"] = msg
|
||||
a._session_webhooks["chat-1"] = (
|
||||
"https://api.dingtalk.com/x", 9999999999999,
|
||||
)
|
||||
return a
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_reply_finalizes_card(self, adapter_with_card):
|
||||
"""send(reply_to=...) creates a closed card (final response path)."""
|
||||
a = adapter_with_card
|
||||
result = await a.send("chat-1", "Hello", reply_to="user-msg-1")
|
||||
assert result.success
|
||||
call = a._card_sdk.streaming_update_with_options_async.call_args
|
||||
assert call[0][0].is_finalize is True
|
||||
# Not tracked as streaming — it's already closed.
|
||||
assert "chat-1" not in a._streaming_cards
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intermediate_send_stays_streaming(self, adapter_with_card):
|
||||
"""send() without reply_to creates an OPEN card (tool progress /
|
||||
commentary / streaming first chunk). No flicker closed→streaming
|
||||
when edit_message follows."""
|
||||
a = adapter_with_card
|
||||
result = await a.send("chat-1", "💻 terminal: ls")
|
||||
assert result.success
|
||||
call = a._card_sdk.streaming_update_with_options_async.call_args
|
||||
assert call[0][0].is_finalize is False
|
||||
# Tracked for sibling cleanup.
|
||||
assert result.message_id in a._streaming_cards.get("chat-1", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_done_fires_only_when_reply_to_is_set(self, adapter_with_card):
|
||||
"""reply_to distinguishes final response (base.py) from tool-progress
|
||||
sends (run.py). Done must only fire for the former."""
|
||||
a = adapter_with_card
|
||||
fired: list[str] = []
|
||||
a._fire_done_reaction = lambda cid: fired.append(cid)
|
||||
|
||||
# Tool-progress / commentary path: no reply_to — no Done.
|
||||
await a.send("chat-1", "tool line")
|
||||
assert fired == []
|
||||
|
||||
# Final response path: reply_to set — Done fires.
|
||||
await a.send("chat-1", "final", reply_to="user-msg-1")
|
||||
assert fired == ["chat-1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_finalize_fires_done(self, adapter_with_card):
|
||||
"""Stream consumer's final edit_message(finalize=True) fires Done."""
|
||||
a = adapter_with_card
|
||||
fired: list[str] = []
|
||||
a._fire_done_reaction = lambda cid: fired.append(cid)
|
||||
|
||||
await a.send("chat-1", "initial")
|
||||
# Reopen via edit_message(finalize=False) then close.
|
||||
await a.edit_message(
|
||||
chat_id="chat-1", message_id="track-X",
|
||||
content="streaming...", finalize=False,
|
||||
)
|
||||
await a.edit_message(
|
||||
chat_id="chat-1", message_id="track-X",
|
||||
content="final", finalize=True,
|
||||
)
|
||||
assert "chat-1" in fired
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_finalize_false_tracks_sibling(self, adapter_with_card):
|
||||
"""After edit_message(finalize=False), card is tracked as open."""
|
||||
a = adapter_with_card
|
||||
await a.edit_message(
|
||||
chat_id="chat-1", message_id="track-1",
|
||||
content="partial", finalize=False,
|
||||
)
|
||||
assert "chat-1" in a._streaming_cards
|
||||
assert a._streaming_cards["chat-1"].get("track-1") == "partial"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_send_auto_closes_sibling_streaming_cards(
|
||||
self, adapter_with_card,
|
||||
):
|
||||
"""Tool-progress card left open (send without reply_to + edits) must
|
||||
be auto-closed when the final-reply send arrives."""
|
||||
a = adapter_with_card
|
||||
# First tool: intermediate send — card stays open.
|
||||
r1 = await a.send("chat-1", "💻 tool1")
|
||||
# Second tool: edit_message(finalize=False) — keeps streaming.
|
||||
await a.edit_message(
|
||||
chat_id="chat-1", message_id=r1.message_id,
|
||||
content="💻 tool1\n💻 tool2", finalize=False,
|
||||
)
|
||||
assert r1.message_id in a._streaming_cards.get("chat-1", {})
|
||||
a._card_sdk.streaming_update_with_options_async.reset_mock()
|
||||
|
||||
# Final response send auto-closes the sibling.
|
||||
await a.send("chat-1", "final answer", reply_to="user-msg")
|
||||
|
||||
calls = a._card_sdk.streaming_update_with_options_async.call_args_list
|
||||
assert len(calls) >= 2
|
||||
# First call was the sibling close with last-seen tool-progress content.
|
||||
first_req = calls[0][0][0]
|
||||
assert first_req.out_track_id == r1.message_id
|
||||
assert first_req.is_finalize is True
|
||||
assert "tool1" in first_req.content
|
||||
# Streaming tracking is cleared after close.
|
||||
assert "chat-1" not in a._streaming_cards
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_requires_message_id(self, adapter_with_card):
|
||||
a = adapter_with_card
|
||||
result = await a.edit_message(
|
||||
chat_id="chat-1", message_id="", content="x", finalize=True,
|
||||
)
|
||||
assert result.success is False
|
||||
a._card_sdk.streaming_update_with_options_async.assert_not_called()
|
||||
|
||||
def test_fire_done_reaction_is_idempotent(self, adapter_with_card):
|
||||
a = adapter_with_card
|
||||
captured = []
|
||||
def _capture(coro):
|
||||
captured.append(coro)
|
||||
a._spawn_bg = _capture
|
||||
|
||||
a._fire_done_reaction("chat-1")
|
||||
a._fire_done_reaction("chat-1")
|
||||
assert len(captured) == 1
|
||||
captured[0].close()
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Card Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDingTalkAdapterAICards:
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
return PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"client_id": "test_id",
|
||||
"client_secret": "test_secret",
|
||||
"card_template_id": "test_card_template",
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_stream_client(self):
|
||||
client = MagicMock()
|
||||
client.get_access_token = MagicMock(return_value="test_token")
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client(self):
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
msg = MagicMock()
|
||||
msg.message_id = "test_msg_id"
|
||||
msg.conversation_id = "test_conv_id"
|
||||
msg.conversation_type = "1"
|
||||
msg.sender_id = "sender1"
|
||||
msg.sender_nick = "Test User"
|
||||
msg.sender_staff_id = "staff1"
|
||||
msg.text = MagicMock(content="Hello")
|
||||
msg.session_webhook = "https://api.dingtalk.com/robot/sendBySession?session=test"
|
||||
msg.session_webhook_expired_time = 999999999999
|
||||
msg.create_at = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||
msg.at_users = []
|
||||
return msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(config)
|
||||
adapter._stream_client = mock_stream_client
|
||||
adapter._http_client = mock_http_client
|
||||
adapter._message_contexts["test_conv_id"] = mock_message
|
||||
adapter._session_webhooks = {"test_conv_id": ("https://api.dingtalk.com/robot/sendBySession?session=test", 9999999999999)}
|
||||
adapter._card_template_id = "test_card_template"
|
||||
|
||||
# Mock the card SDK with proper async methods
|
||||
mock_card_sdk = MagicMock()
|
||||
mock_card_sdk.create_card_with_options_async = AsyncMock()
|
||||
mock_card_sdk.deliver_card_with_options_async = AsyncMock()
|
||||
mock_card_sdk.streaming_update_with_options_async = AsyncMock()
|
||||
adapter._card_sdk = mock_card_sdk
|
||||
|
||||
# Mock access token
|
||||
adapter._get_access_token = AsyncMock(return_value="test_token")
|
||||
|
||||
result = await adapter.send("test_conv_id", "Hello World")
|
||||
|
||||
mock_card_sdk.create_card_with_options_async.assert_called_once()
|
||||
mock_card_sdk.deliver_card_with_options_async.assert_called_once()
|
||||
mock_card_sdk.streaming_update_with_options_async.assert_called_once()
|
||||
assert result.success is True
|
||||
|
||||
155
tests/gateway/test_discord_allowed_mentions.py
Normal file
155
tests/gateway/test_discord_allowed_mentions.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for the Discord ``allowed_mentions`` safe-default helper.
|
||||
|
||||
Ensures the bot defaults to blocking ``@everyone`` / ``@here`` / role pings
|
||||
so an LLM response (or echoed user content) can't spam a whole server —
|
||||
and that the four ``DISCORD_ALLOW_MENTION_*`` env vars correctly opt back
|
||||
in when an operator explicitly wants a different policy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeAllowedMentions:
|
||||
"""Stand-in for ``discord.AllowedMentions`` that exposes the same four
|
||||
boolean flags as real attributes so the test can assert on them.
|
||||
"""
|
||||
|
||||
def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
|
||||
self.everyone = everyone
|
||||
self.roles = roles
|
||||
self.users = users
|
||||
self.replied_user = replied_user
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
f"AllowedMentions(everyone={self.everyone}, roles={self.roles}, "
|
||||
f"users={self.users}, replied_user={self.replied_user})"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
"""Install (or augment) a mock ``discord`` module.
|
||||
|
||||
Other test modules in this directory stub ``discord`` via
|
||||
``sys.modules.setdefault`` — whichever test file imports first wins and
|
||||
our full module is then silently dropped. We therefore ALWAYS force
|
||||
``AllowedMentions`` onto whatever is currently in ``sys.modules["discord"]``;
|
||||
that's the only attribute this test file actually needs real behavior from.
|
||||
"""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
|
||||
return
|
||||
|
||||
if sys.modules.get("discord") is None:
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules["discord"] = discord_mod
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
# Whether we just installed the mock OR the mock was already installed
|
||||
# by another test's _ensure_discord_mock, force the AllowedMentions
|
||||
# stand-in onto it — _build_allowed_mentions() reads this attribute.
|
||||
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import _build_allowed_mentions # noqa: E402
|
||||
|
||||
|
||||
# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.
|
||||
# Cleared before each test so env leakage from other tests never masks a regression.
|
||||
_ENV_VARS = (
|
||||
"DISCORD_ALLOW_MENTION_EVERYONE",
|
||||
"DISCORD_ALLOW_MENTION_ROLES",
|
||||
"DISCORD_ALLOW_MENTION_USERS",
|
||||
"DISCORD_ALLOW_MENTION_REPLIED_USER",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_allowed_mention_env(monkeypatch):
|
||||
for name in _ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_safe_defaults_block_everyone_and_roles():
|
||||
am = _build_allowed_mentions()
|
||||
assert am.everyone is False, "default must NOT allow @everyone/@here pings"
|
||||
assert am.roles is False, "default must NOT allow role pings"
|
||||
assert am.users is True, "default must allow user pings so replies work"
|
||||
assert am.replied_user is True, "default must allow reply-reference pings"
|
||||
|
||||
|
||||
def test_env_var_opts_back_into_everyone(monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true")
|
||||
am = _build_allowed_mentions()
|
||||
assert am.everyone is True
|
||||
# other defaults unaffected
|
||||
assert am.roles is False
|
||||
assert am.users is True
|
||||
assert am.replied_user is True
|
||||
|
||||
|
||||
def test_env_var_can_disable_users(monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false")
|
||||
am = _build_allowed_mentions()
|
||||
assert am.users is False
|
||||
# safe defaults elsewhere remain
|
||||
assert am.everyone is False
|
||||
assert am.roles is False
|
||||
assert am.replied_user is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw, expected", [
|
||||
("true", True), ("True", True), ("TRUE", True),
|
||||
("1", True), ("yes", True), ("YES", True), ("on", True),
|
||||
("false", False), ("False", False), ("0", False),
|
||||
("no", False), ("off", False),
|
||||
("", False), # empty falls back to default (False for everyone)
|
||||
("garbage", False), # unknown falls back to default
|
||||
(" true ", True), # whitespace tolerated
|
||||
])
|
||||
def test_everyone_boolean_parsing(monkeypatch, raw, expected):
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", raw)
|
||||
am = _build_allowed_mentions()
|
||||
assert am.everyone is expected
|
||||
|
||||
|
||||
def test_all_four_knobs_together(monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true")
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_ROLES", "true")
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false")
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_REPLIED_USER", "false")
|
||||
am = _build_allowed_mentions()
|
||||
assert am.everyone is True
|
||||
assert am.roles is True
|
||||
assert am.users is False
|
||||
assert am.replied_user is False
|
||||
360
tests/gateway/test_discord_attachment_download.py
Normal file
360
tests/gateway/test_discord_attachment_download.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Tests for Discord attachment downloads via the authenticated bot session.
|
||||
|
||||
Covers the three download paths (image / audio / document) in
|
||||
``DiscordAdapter._handle_message()`` and the shared ``_cache_discord_*``
|
||||
helpers. Verifies that:
|
||||
|
||||
- ``att.read()`` is preferred over the legacy URL-based downloaders so
|
||||
that Discord's CDN auth (and user-environment DNS quirks) can't block
|
||||
media caching. (issues #8242 image 403s, #6587 CDN SSRF false-positives)
|
||||
- Falls back cleanly to the SSRF-gated ``cache_*_from_url`` helpers
|
||||
(image/audio) or SSRF-gated aiohttp (documents) when ``att.read()``
|
||||
isn't available or fails.
|
||||
- The document fallback path now runs through the SSRF gate for
|
||||
defense-in-depth. (issue #11345)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
"""Install a mock discord module when discord.py isn't available."""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
# Minimal valid image / audio / PDF bytes so the cache_*_from_bytes
|
||||
# validators accept them. cache_image_from_bytes runs _looks_like_image()
|
||||
# which checks for magic bytes; PNG's magic is sufficient.
|
||||
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
_OGG_BYTES = b"OggS" + b"\x00" * 60
|
||||
_PDF_BYTES = b"%PDF-1.4\n" + b"fake pdf body" + b"\n%%EOF"
|
||||
|
||||
|
||||
def _make_adapter() -> DiscordAdapter:
|
||||
return DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
|
||||
def _make_attachment_with_read(payload: bytes) -> SimpleNamespace:
|
||||
"""Attachment stub that exposes .read() — the happy-path primary."""
|
||||
return SimpleNamespace(
|
||||
url="https://cdn.discordapp.com/attachments/fake/file.png",
|
||||
filename="file.png",
|
||||
size=len(payload),
|
||||
read=AsyncMock(return_value=payload),
|
||||
)
|
||||
|
||||
|
||||
def _make_attachment_without_read() -> SimpleNamespace:
|
||||
"""Attachment stub that has no .read() — exercises the URL fallback."""
|
||||
return SimpleNamespace(
|
||||
url="https://cdn.discordapp.com/attachments/fake/file.png",
|
||||
filename="file.png",
|
||||
size=1024,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_attachment_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadAttachmentBytes:
|
||||
"""Unit tests for the low-level att.read() wrapper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_bytes_on_successful_read(self):
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_with_read(b"hello world")
|
||||
|
||||
result = await adapter._read_attachment_bytes(att)
|
||||
|
||||
assert result == b"hello world"
|
||||
att.read.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_read_missing(self):
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
result = await adapter._read_attachment_bytes(att)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_read_raises(self):
|
||||
"""Bot-session fetch failures are swallowed so callers fall back."""
|
||||
adapter = _make_adapter()
|
||||
att = SimpleNamespace(
|
||||
url="https://cdn.discordapp.com/attachments/fake/file.png",
|
||||
filename="file.png",
|
||||
read=AsyncMock(side_effect=RuntimeError("403 Forbidden")),
|
||||
)
|
||||
|
||||
result = await adapter._read_attachment_bytes(att)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cache_discord_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheDiscordImage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_att_read_over_url(self):
|
||||
"""Primary path: att.read() bytes → cache_image_from_bytes, no URL fetch."""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_with_read(_PNG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
return_value="/tmp/cached.png",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_image(att, ".png")
|
||||
|
||||
assert result == "/tmp/cached.png"
|
||||
mock_bytes.assert_called_once_with(_PNG_BYTES, ext=".png")
|
||||
mock_url.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_url_when_no_read(self):
|
||||
"""No .read() → URL path is used (existing SSRF-gated behavior)."""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.png",
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_image(att, ".png")
|
||||
|
||||
assert result == "/tmp/from_url.png"
|
||||
mock_bytes.assert_not_called()
|
||||
mock_url.assert_awaited_once_with(att.url, ext=".png")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_url_when_bytes_validator_rejects(self):
|
||||
"""If att.read() returns garbage that cache_image_from_bytes rejects
|
||||
(e.g. an HTML error page), fall back to the URL downloader instead
|
||||
of surfacing the validation error to the caller."""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_with_read(b"<html>forbidden</html>")
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
side_effect=ValueError("not a valid image"),
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/fallback.png",
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_image(att, ".png")
|
||||
|
||||
assert result == "/tmp/fallback.png"
|
||||
mock_url.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cache_discord_audio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheDiscordAudio:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_att_read_over_url(self):
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_with_read(_OGG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
return_value="/tmp/voice.ogg",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_audio(att, ".ogg")
|
||||
|
||||
assert result == "/tmp/voice.ogg"
|
||||
mock_bytes.assert_called_once_with(_OGG_BYTES, ext=".ogg")
|
||||
mock_url.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_url_when_no_read(self):
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.ogg",
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_audio(att, ".ogg")
|
||||
|
||||
assert result == "/tmp/from_url.ogg"
|
||||
mock_url.assert_awaited_once_with(att.url, ext=".ogg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cache_discord_document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheDiscordDocument:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_att_read_returns_bytes_directly(self):
|
||||
"""Primary path: att.read() → raw bytes, no aiohttp involvement."""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_with_read(_PDF_BYTES)
|
||||
|
||||
with patch("aiohttp.ClientSession") as mock_session:
|
||||
result = await adapter._cache_discord_document(att, ".pdf")
|
||||
|
||||
assert result == _PDF_BYTES
|
||||
mock_session.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_blocked_by_ssrf_guard(self):
|
||||
"""Document fallback path now honors is_safe_url — was missing before.
|
||||
|
||||
Regression guard for #11345: the old aiohttp block skipped the
|
||||
SSRF check entirely; a non-CDN ``att.url`` could have reached
|
||||
internal-looking hosts. The fallback must now refuse unsafe URLs.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_without_read() # no .read → forces fallback
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=False
|
||||
) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
|
||||
with pytest.raises(ValueError, match="SSRF"):
|
||||
await adapter._cache_discord_document(att, ".pdf")
|
||||
|
||||
mock_safe.assert_called_once_with(att.url)
|
||||
# aiohttp must NOT be contacted when the URL is blocked.
|
||||
mock_session.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_aiohttp_when_safe_url(self):
|
||||
"""Safe URL + no att.read() → aiohttp fallback executes."""
|
||||
adapter = _make_adapter()
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
# Build an aiohttp session mock that returns 200 + payload.
|
||||
resp = AsyncMock()
|
||||
resp.status = 200
|
||||
resp.read = AsyncMock(return_value=_PDF_BYTES)
|
||||
resp.__aenter__ = AsyncMock(return_value=resp)
|
||||
resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
session = AsyncMock()
|
||||
session.get = MagicMock(return_value=resp)
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=True
|
||||
), patch("aiohttp.ClientSession", return_value=session):
|
||||
result = await adapter._cache_discord_document(att, ".pdf")
|
||||
|
||||
assert result == _PDF_BYTES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: end-to-end via _handle_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHandleMessageUsesAuthenticatedRead:
|
||||
"""E2E: verify _handle_message routes image/audio downloads through
|
||||
att.read() so cdn.discordapp.com 403s (#8242) and SSRF false-positives
|
||||
on mangled DNS (#6587) no longer block media caching.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_downloads_via_att_read_not_url(self, monkeypatch):
|
||||
"""Image attachments with .read() never call cache_image_from_url."""
|
||||
adapter = _make_adapter()
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
return_value="/tmp/img_from_read.png",
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url_download:
|
||||
att = SimpleNamespace(
|
||||
url="https://cdn.discordapp.com/attachments/fake/file.png",
|
||||
filename="file.png",
|
||||
content_type="image/png",
|
||||
size=len(_PNG_BYTES),
|
||||
read=AsyncMock(return_value=_PNG_BYTES),
|
||||
)
|
||||
# Minimal Discord message stub for _handle_message.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class _FakeDMChannel:
|
||||
id = 100
|
||||
name = "dm"
|
||||
|
||||
# Patch the DMChannel isinstance check so our fake counts as DM.
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
msg = SimpleNamespace(
|
||||
id=1, content="", attachments=[att], mentions=[],
|
||||
reference=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
channel=chan,
|
||||
author=SimpleNamespace(id=42, display_name="U", name="U"),
|
||||
)
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
mock_url_download.assert_not_called()
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert event.media_urls == ["/tmp/img_from_read.png"]
|
||||
assert event.media_types == ["image/png"]
|
||||
226
tests/gateway/test_discord_bot_auth_bypass.py
Normal file
226
tests/gateway/test_discord_bot_auth_bypass.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS.
|
||||
|
||||
The bug had two sequential gates both rejecting bot messages:
|
||||
|
||||
Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist
|
||||
check BEFORE the bot filter, so bot senders were dropped with a warning
|
||||
before the DISCORD_ALLOW_BOTS policy was ever evaluated.
|
||||
|
||||
Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the
|
||||
gateway level even if they somehow reached that layer.
|
||||
|
||||
These tests assert both gates now pass a bot message through when
|
||||
DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.session import Platform, SessionSource
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_discord_env(monkeypatch):
|
||||
"""Make every test start with a clean Discord env so prior tests in the
|
||||
session (or CI setups) can't leak DISCORD_ALLOWED_ROLES / DISCORD_ALLOWED_USERS
|
||||
/ DISCORD_ALLOW_BOTS and silently flip the auth result.
|
||||
"""
|
||||
for var in (
|
||||
"DISCORD_ALLOW_BOTS",
|
||||
"DISCORD_ALLOWED_USERS",
|
||||
"DISCORD_ALLOWED_ROLES",
|
||||
"DISCORD_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gate 2: _is_user_authorized bypasses allowlist for permitted bots
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_bare_runner():
|
||||
"""Build a GatewayRunner skeleton with just enough wiring for the auth test.
|
||||
|
||||
Uses ``object.__new__`` to skip the heavy __init__ — many gateway tests
|
||||
use this pattern (see AGENTS.md pitfall #17).
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
runner = object.__new__(GatewayRunner)
|
||||
# _is_user_authorized reads self.pairing_store.is_approved(...) before
|
||||
# any allowlist check succeeds; stub it to never approve so we exercise
|
||||
# the real allowlist path.
|
||||
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
|
||||
return runner
|
||||
|
||||
|
||||
def _make_discord_bot_source(bot_id: str = "999888777"):
|
||||
return SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="channel",
|
||||
user_id=bot_id,
|
||||
user_name="SomeBot",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_discord_human_source(user_id: str = "100200300"):
|
||||
return SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="channel",
|
||||
user_id=user_id,
|
||||
user_name="SomeHuman",
|
||||
is_bot=False,
|
||||
)
|
||||
|
||||
|
||||
def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):
|
||||
"""DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when
|
||||
DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it.
|
||||
|
||||
This is the exact scenario from #4466 — a Cloudflare Worker webhook
|
||||
posts Notion events to Discord, the Hermes bot gets @mentioned, and
|
||||
the webhook's bot ID is not (and shouldn't be) on the human
|
||||
allowlist.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") # human-only allowlist
|
||||
|
||||
source = _make_discord_bot_source(bot_id="999888777")
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_discord_bot_authorized_when_allow_bots_all(monkeypatch):
|
||||
"""DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass."""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
source = _make_discord_bot_source()
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_discord_bot_NOT_authorized_when_allow_bots_none(monkeypatch):
|
||||
"""DISCORD_ALLOW_BOTS=none (default) must still reject bots that aren't
|
||||
in DISCORD_ALLOWED_USERS — preserves the original security behavior.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
source = _make_discord_bot_source(bot_id="999888777")
|
||||
assert runner._is_user_authorized(source) is False
|
||||
|
||||
|
||||
def test_discord_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):
|
||||
"""Unset DISCORD_ALLOW_BOTS must behave like 'none'."""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
source = _make_discord_bot_source(bot_id="999888777")
|
||||
assert runner._is_user_authorized(source) is False
|
||||
|
||||
|
||||
def test_discord_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):
|
||||
"""DISCORD_ALLOW_BOTS=all must NOT open the gate for humans — they
|
||||
still need to be in DISCORD_ALLOWED_USERS (or a pairing approval).
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
# Human NOT on the allowlist → must be rejected.
|
||||
source = _make_discord_human_source(user_id="999999999")
|
||||
assert runner._is_user_authorized(source) is False
|
||||
|
||||
# Human ON the allowlist → accepted.
|
||||
source_allowed = _make_discord_human_source(user_id="100200300")
|
||||
assert runner._is_user_authorized(source_allowed) is True
|
||||
|
||||
|
||||
def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
|
||||
"""The is_bot bypass is Discord-specific — a Telegram bot source with
|
||||
is_bot=True must NOT be authorized just because DISCORD_ALLOW_BOTS=all.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
|
||||
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "100200300")
|
||||
|
||||
telegram_bot = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="channel",
|
||||
user_id="999888777",
|
||||
is_bot=True,
|
||||
)
|
||||
assert runner._is_user_authorized(telegram_bot) is False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# DISCORD_ALLOWED_ROLES gateway-layer bypass (#7871)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discord_role_config_bypasses_gateway_allowlist(monkeypatch):
|
||||
"""When DISCORD_ALLOWED_ROLES is set, _is_user_authorized must trust
|
||||
the adapter's pre-filter and authorize. Without this, role-only setups
|
||||
(DISCORD_ALLOWED_ROLES populated, DISCORD_ALLOWED_USERS empty) would
|
||||
hit the 'no allowlists configured' branch and get rejected.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
|
||||
# Note: DISCORD_ALLOWED_USERS is NOT set — the entire point.
|
||||
|
||||
source = _make_discord_human_source(user_id="999888777")
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_discord_role_config_still_authorizes_alongside_users(monkeypatch):
|
||||
"""Sanity: setting both DISCORD_ALLOWED_ROLES and DISCORD_ALLOWED_USERS
|
||||
doesn't break the user-id path. Users in the allowlist should still be
|
||||
authorized even if they don't have a role. (OR semantics.)
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
# User on the user allowlist, no role → still authorized at gateway
|
||||
# level via the role bypass (adapter already approved them).
|
||||
source = _make_discord_human_source(user_id="100200300")
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_discord_role_bypass_does_not_leak_to_other_platforms(monkeypatch):
|
||||
"""DISCORD_ALLOWED_ROLES must only affect Discord. Setting it should
|
||||
not suddenly start authorizing Telegram users whose platform has its
|
||||
own empty allowlist.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
|
||||
# Telegram has its own empty allowlist and no allow-all flag.
|
||||
|
||||
telegram_user = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="channel",
|
||||
user_id="999888777",
|
||||
)
|
||||
assert runner._is_user_authorized(telegram_user) is False
|
||||
258
tests/gateway/test_discord_channel_prompts.py
Normal file
258
tests/gateway/test_discord_channel_prompts.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Tests for Discord channel_prompts resolution and injection."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
discord_mod = types.ModuleType("discord")
|
||||
discord_mod.Intents = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
last_init = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
type(self).last_init = dict(kwargs)
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_agent(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
_ensure_discord_mock()
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = "Global prompt"
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._session_model_overrides = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(streaming=None)
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"),
|
||||
load_transcript=lambda session_id: [],
|
||||
)
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED")
|
||||
return runner
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="12345",
|
||||
chat_type="thread",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
class TestResolveChannelPrompts:
|
||||
def test_no_prompt_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._resolve_channel_prompt("123") is None
|
||||
|
||||
def test_match_by_channel_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"100": "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") == "Research mode"
|
||||
|
||||
def test_numeric_yaml_keys_normalized_at_config_load(self):
|
||||
"""Numeric YAML keys are normalized to strings by config bridging.
|
||||
|
||||
The resolver itself expects string keys (config.py handles normalization),
|
||||
so raw numeric keys will not match — this is intentional.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
# Simulates post-bridging state: keys are already strings
|
||||
adapter.config.extra = {"channel_prompts": {"100": "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") == "Research mode"
|
||||
# Pre-bridging numeric key would not match (bridging is responsible)
|
||||
adapter.config.extra = {"channel_prompts": {100: "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") is None
|
||||
|
||||
def test_match_by_parent_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"200": "Forum prompt"}}
|
||||
assert adapter._resolve_channel_prompt("999", parent_id="200") == "Forum prompt"
|
||||
|
||||
def test_exact_channel_overrides_parent(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_prompts": {
|
||||
"999": "Thread override",
|
||||
"200": "Forum prompt",
|
||||
}
|
||||
}
|
||||
assert adapter._resolve_channel_prompt("999", parent_id="200") == "Thread override"
|
||||
|
||||
def test_build_message_event_sets_channel_prompt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"321": "Command prompt"}}
|
||||
adapter.build_source = MagicMock(return_value=SimpleNamespace())
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
channel_id=321,
|
||||
channel=SimpleNamespace(name="general", guild=None, parent_id=None),
|
||||
user=SimpleNamespace(id=1, display_name="Brenner"),
|
||||
)
|
||||
adapter._get_effective_topic = MagicMock(return_value=None)
|
||||
|
||||
event = adapter._build_slash_event(interaction, "/retry")
|
||||
|
||||
assert event.channel_prompt == "Command prompt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_thread_session_inherits_parent_channel_prompt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"200": "Parent prompt"}}
|
||||
adapter.build_source = MagicMock(return_value=SimpleNamespace())
|
||||
adapter._get_effective_topic = MagicMock(return_value=None)
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
guild=SimpleNamespace(name="Wetlands"),
|
||||
channel=SimpleNamespace(id=200, parent=None),
|
||||
user=SimpleNamespace(id=1, display_name="Brenner"),
|
||||
)
|
||||
|
||||
await adapter._dispatch_thread_session(interaction, "999", "new-thread", "hello")
|
||||
|
||||
dispatched_event = adapter.handle_message.await_args.args[0]
|
||||
assert dispatched_event.channel_prompt == "Parent prompt"
|
||||
|
||||
def test_blank_prompts_are_ignored(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"100": " "}}
|
||||
assert adapter._resolve_channel_prompt("100") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_preserves_channel_prompt(monkeypatch):
|
||||
runner = _make_runner()
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1", last_prompt_tokens=10),
|
||||
load_transcript=lambda session_id: [
|
||||
{"role": "user", "content": "original message"},
|
||||
{"role": "assistant", "content": "old reply"},
|
||||
],
|
||||
rewrite_transcript=MagicMock(),
|
||||
)
|
||||
runner._handle_message = AsyncMock(return_value="ok")
|
||||
|
||||
event = MessageEvent(
|
||||
text="/retry",
|
||||
message_type=gateway_run.MessageType.COMMAND,
|
||||
source=_make_source(),
|
||||
raw_message=SimpleNamespace(),
|
||||
channel_prompt="Channel prompt",
|
||||
)
|
||||
|
||||
result = await runner._handle_retry_command(event)
|
||||
|
||||
assert result == "ok"
|
||||
retried_event = runner._handle_message.await_args.args[0]
|
||||
assert retried_event.channel_prompt == "Channel prompt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_appends_channel_prompt_to_ephemeral_system_prompt(monkeypatch, tmp_path):
|
||||
_install_fake_agent(monkeypatch)
|
||||
runner = _make_runner()
|
||||
|
||||
(tmp_path / "config.yaml").write_text("agent:\n system_prompt: Global prompt\n", encoding="utf-8")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
event = MessageEvent(
|
||||
text="hi",
|
||||
source=_make_source(),
|
||||
message_id="m1",
|
||||
channel_prompt="Channel prompt",
|
||||
)
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="Context prompt",
|
||||
history=[],
|
||||
source=_make_source(),
|
||||
session_id="session-1",
|
||||
session_key="agent:main:discord:thread:12345",
|
||||
channel_prompt=event.channel_prompt,
|
||||
)
|
||||
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init["ephemeral_system_prompt"] == (
|
||||
"Context prompt\n\nChannel prompt\n\nGlobal prompt"
|
||||
)
|
||||
@@ -8,37 +8,60 @@ import pytest
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
class _FakeAllowedMentions:
|
||||
"""Stand-in for ``discord.AllowedMentions`` — exposes the same four
|
||||
boolean flags as real attributes so tests can assert on safe defaults.
|
||||
"""
|
||||
|
||||
def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
|
||||
self.everyone = everyone
|
||||
self.roles = roles
|
||||
self.users = users
|
||||
self.replied_user = replied_user
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
"""Install (or augment) a mock ``discord`` module.
|
||||
|
||||
Always force ``AllowedMentions`` onto whatever is in ``sys.modules`` —
|
||||
other test files also stub the module via ``setdefault``, and we need
|
||||
``_build_allowed_mentions()``'s return value to have real attribute
|
||||
access regardless of which file loaded first.
|
||||
"""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
|
||||
if sys.modules.get("discord") is None:
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
sys.modules["discord"] = discord_mod
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
@@ -56,8 +79,9 @@ class FakeTree:
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, *, intents, proxy=None):
|
||||
def __init__(self, *, intents, proxy=None, allowed_mentions=None, **_):
|
||||
self.intents = intents
|
||||
self.allowed_mentions = allowed_mentions
|
||||
self.user = SimpleNamespace(id=999, name="Hermes")
|
||||
self._events = {}
|
||||
self.tree = FakeTree()
|
||||
@@ -115,8 +139,8 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None):
|
||||
created["bot"] = FakeBot(intents=intents)
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
|
||||
created["bot"] = FakeBot(intents=intents, allowed_mentions=allowed_mentions)
|
||||
return created["bot"]
|
||||
|
||||
monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
|
||||
@@ -126,6 +150,13 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
|
||||
|
||||
assert ok is True
|
||||
assert created["bot"].intents.members is expected_members_intent
|
||||
# Safe-default AllowedMentions must be applied on every connect so the
|
||||
# bot cannot @everyone from LLM output. Granular overrides live in the
|
||||
# dedicated test_discord_allowed_mentions.py module.
|
||||
am = created["bot"].allowed_mentions
|
||||
assert am is not None, "connect() must pass an AllowedMentions to commands.Bot"
|
||||
assert am.everyone is False
|
||||
assert am.roles is False
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
@@ -144,7 +175,11 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
discord_platform.commands,
|
||||
"Bot",
|
||||
lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")),
|
||||
lambda **kwargs: FakeBot(
|
||||
intents=kwargs["intents"],
|
||||
proxy=kwargs.get("proxy"),
|
||||
allowed_mentions=kwargs.get("allowed_mentions"),
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_wait_for(awaitable, timeout):
|
||||
@@ -172,7 +207,7 @@ async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None):
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
|
||||
bot = SlowSyncBot(intents=intents, proxy=proxy)
|
||||
created["bot"] = bot
|
||||
return bot
|
||||
|
||||
@@ -96,7 +96,7 @@ def adapter(monkeypatch):
|
||||
return adapter
|
||||
|
||||
|
||||
def make_message(*, channel, content: str, mentions=None):
|
||||
def make_message(*, channel, content: str, mentions=None, msg_type=None):
|
||||
author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza")
|
||||
return SimpleNamespace(
|
||||
id=123,
|
||||
@@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None):
|
||||
created_at=datetime.now(timezone.utc),
|
||||
channel=channel,
|
||||
author=author,
|
||||
type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default,
|
||||
)
|
||||
|
||||
|
||||
@@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt
|
||||
assert event.text == "allowed without mention"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch):
|
||||
monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
adapter.config.extra["free_response_channels"] = ["789", "999"]
|
||||
|
||||
message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed from config")
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "allowed from config"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
@@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
|
||||
assert event.source.thread_id == "999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):
|
||||
"""Quote-replies should stay in-channel instead of trying to create a thread."""
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=123),
|
||||
content="reply without mention",
|
||||
msg_type=discord_platform.discord.MessageType.reply,
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "reply without mention"
|
||||
assert event.source.chat_id == "123"
|
||||
assert event.source.chat_type == "group"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
|
||||
"""Setting auto_thread to false skips thread creation."""
|
||||
@@ -385,6 +426,33 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t
|
||||
assert event.source.chat_type == "group"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch):
|
||||
"""Free-response channels must NOT auto-create threads — bot replies inline.
|
||||
|
||||
Without this, every message in a free-response channel would spin off a
|
||||
thread (since the channel bypasses the @mention gate), defeating the
|
||||
lightweight-chat purpose of free-response mode.
|
||||
"""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789")
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) # default true
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=789),
|
||||
content="free chat message",
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.source.chat_type == "group"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
|
||||
"""Threads under a voice-linked channel should still require @mention."""
|
||||
|
||||
79
tests/gateway/test_discord_race_polish.py
Normal file
79
tests/gateway/test_discord_race_polish.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Discord adapter race polish: concurrent join_voice_channel must not
|
||||
double-invoke channel.connect() on the same guild."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._platform = Platform.DISCORD
|
||||
adapter.config = PlatformConfig(enabled=True, token="t")
|
||||
adapter._ready_event = asyncio.Event()
|
||||
adapter._allowed_user_ids = set()
|
||||
adapter._allowed_role_ids = set()
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_locks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._client = MagicMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_joins_do_not_double_connect():
|
||||
"""Two concurrent join_voice_channel calls on the same guild must
|
||||
serialize through the per-guild lock — only ONE channel.connect()
|
||||
actually fires; the second sees the _voice_clients entry the first
|
||||
just installed."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
connect_count = [0]
|
||||
release = asyncio.Event()
|
||||
|
||||
class FakeVC:
|
||||
def __init__(self, channel):
|
||||
self.channel = channel
|
||||
|
||||
def is_connected(self):
|
||||
return True
|
||||
|
||||
async def move_to(self, _channel):
|
||||
return None
|
||||
|
||||
async def slow_connect(self):
|
||||
connect_count[0] += 1
|
||||
await release.wait()
|
||||
return FakeVC(self)
|
||||
|
||||
channel = MagicMock()
|
||||
channel.id = 111
|
||||
channel.guild.id = 42
|
||||
channel.connect = lambda: slow_connect(channel)
|
||||
|
||||
from gateway.platforms import discord as discord_mod
|
||||
with patch.object(discord_mod, "VoiceReceiver",
|
||||
MagicMock(return_value=MagicMock(start=lambda: None))):
|
||||
with patch.object(discord_mod.asyncio, "ensure_future",
|
||||
lambda _c: asyncio.create_task(asyncio.sleep(0))):
|
||||
t1 = asyncio.create_task(adapter.join_voice_channel(channel))
|
||||
t2 = asyncio.create_task(adapter.join_voice_channel(channel))
|
||||
await asyncio.sleep(0.05)
|
||||
release.set()
|
||||
r1, r2 = await asyncio.gather(t1, t2)
|
||||
|
||||
assert connect_count[0] == 1, (
|
||||
f"expected 1 channel.connect() call, got {connect_count[0]} — "
|
||||
"per-guild lock is not serializing join_voice_channel"
|
||||
)
|
||||
assert r1 is True and r2 is True
|
||||
assert 42 in adapter._voice_clients
|
||||
@@ -105,9 +105,14 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
|
||||
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
|
||||
adapter = DiscordAdapter(config)
|
||||
|
||||
# Mock the Discord client and channel
|
||||
# Mock the Discord client and channel.
|
||||
# ref_message.to_reference() → a distinct sentinel: the adapter now wraps
|
||||
# the fetched Message via to_reference(fail_if_not_exists=False) so a
|
||||
# deleted target degrades to "send without reply chip" instead of a 400.
|
||||
mock_channel = AsyncMock()
|
||||
ref_message = MagicMock()
|
||||
ref_reference = MagicMock(name="MessageReference")
|
||||
ref_message.to_reference = MagicMock(return_value=ref_reference)
|
||||
mock_channel.fetch_message = AsyncMock(return_value=ref_message)
|
||||
|
||||
sent_msg = MagicMock()
|
||||
@@ -118,7 +123,9 @@ def _make_discord_adapter(reply_to_mode: str = "first"):
|
||||
mock_client.get_channel = MagicMock(return_value=mock_channel)
|
||||
|
||||
adapter._client = mock_client
|
||||
return adapter, mock_channel, ref_message
|
||||
# Return the reference sentinel alongside so tests can assert identity.
|
||||
adapter._test_expected_reference = ref_reference
|
||||
return adapter, mock_channel, ref_reference
|
||||
|
||||
|
||||
class TestSendWithReplyToMode:
|
||||
@@ -284,9 +291,20 @@ class TestEnvVarOverride:
|
||||
# Tests for reply_to_text extraction in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class FakeDMChannel:
|
||||
# Build FakeDMChannel as a subclass of the real discord.DMChannel when the
|
||||
# library is installed — this guarantees isinstance() checks pass in
|
||||
# production code regardless of test ordering or monkeypatch state.
|
||||
try:
|
||||
import discord as _discord_lib
|
||||
_DMChannelBase = _discord_lib.DMChannel
|
||||
except (ImportError, AttributeError):
|
||||
_DMChannelBase = object
|
||||
|
||||
|
||||
class FakeDMChannel(_DMChannelBase):
|
||||
"""Minimal DM channel stub (skips mention / channel-allow checks)."""
|
||||
def __init__(self, channel_id: int = 100, name: str = "dm"):
|
||||
# Do NOT call super().__init__() — real DMChannel requires State
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
|
||||
@@ -309,10 +327,6 @@ def _make_message(*, content: str = "hi", reference=None):
|
||||
@pytest.fixture
|
||||
def reply_text_adapter(monkeypatch):
|
||||
"""DiscordAdapter wired for _handle_message → handle_message capture."""
|
||||
import gateway.platforms.discord as discord_platform
|
||||
|
||||
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
|
||||
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
|
||||
@@ -48,7 +48,8 @@ from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
async def test_send_retries_without_reference_when_reply_target_is_system_message():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
ref_msg = SimpleNamespace(id=99)
|
||||
reference_obj = object()
|
||||
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
|
||||
sent_msg = SimpleNamespace(id=1234)
|
||||
send_calls = []
|
||||
|
||||
@@ -76,5 +77,312 @@ async def test_send_retries_without_reference_when_reply_target_is_system_messag
|
||||
assert result.message_id == "1234"
|
||||
assert channel.fetch_message.await_count == 1
|
||||
assert channel.send.await_count == 2
|
||||
assert send_calls[0]["reference"] is ref_msg
|
||||
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
|
||||
assert send_calls[0]["reference"] is reference_obj
|
||||
assert send_calls[1]["reference"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_retries_without_reference_when_reply_target_is_deleted():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
reference_obj = object()
|
||||
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
|
||||
sent_msgs = [SimpleNamespace(id=1001), SimpleNamespace(id=1002)]
|
||||
send_calls = []
|
||||
|
||||
async def fake_send(*, content, reference=None):
|
||||
send_calls.append({"content": content, "reference": reference})
|
||||
if len(send_calls) == 1:
|
||||
raise RuntimeError(
|
||||
"400 Bad Request (error code: 10008): Unknown Message"
|
||||
)
|
||||
return sent_msgs[len(send_calls) - 2]
|
||||
|
||||
channel = SimpleNamespace(
|
||||
fetch_message=AsyncMock(return_value=ref_msg),
|
||||
send=AsyncMock(side_effect=fake_send),
|
||||
)
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
long_text = "A" * (adapter.MAX_MESSAGE_LENGTH + 50)
|
||||
result = await adapter.send("555", long_text, reply_to="99")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "1001"
|
||||
assert channel.fetch_message.await_count == 1
|
||||
assert channel.send.await_count == 3
|
||||
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
|
||||
assert send_calls[0]["reference"] is reference_obj
|
||||
assert send_calls[1]["reference"] is None
|
||||
assert send_calls[2]["reference"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_does_not_retry_on_unrelated_errors():
|
||||
"""Regression guard: errors unrelated to the reply reference (e.g. 50013
|
||||
Missing Permissions) must NOT trigger the no-reference retry path — they
|
||||
should propagate out of the per-chunk loop and surface as a failed
|
||||
SendResult so the caller sees the real problem instead of a silent retry.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
reference_obj = object()
|
||||
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
|
||||
send_calls = []
|
||||
|
||||
async def fake_send(*, content, reference=None):
|
||||
send_calls.append({"content": content, "reference": reference})
|
||||
raise RuntimeError(
|
||||
"403 Forbidden (error code: 50013): Missing Permissions"
|
||||
)
|
||||
|
||||
channel = SimpleNamespace(
|
||||
fetch_message=AsyncMock(return_value=ref_msg),
|
||||
send=AsyncMock(side_effect=fake_send),
|
||||
)
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await adapter.send("555", "hello", reply_to="99")
|
||||
|
||||
# Outer except in adapter.send() wraps propagated errors as SendResult.
|
||||
assert result.success is False
|
||||
assert "50013" in (result.error or "")
|
||||
# Only the first attempt happens — no reference-retry replay.
|
||||
assert channel.send.await_count == 1
|
||||
assert send_calls[0]["reference"] is reference_obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forum channel tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import discord as _discord_mod # noqa: E402 — imported after _ensure_discord_mock
|
||||
|
||||
|
||||
class TestIsForumParent:
|
||||
def test_none_returns_false(self):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
assert adapter._is_forum_parent(None) is False
|
||||
|
||||
def test_forum_channel_class_instance(self):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
forum_cls = getattr(_discord_mod, "ForumChannel", None)
|
||||
if forum_cls is None:
|
||||
# Re-create a type for the mock
|
||||
forum_cls = type("ForumChannel", (), {})
|
||||
_discord_mod.ForumChannel = forum_cls
|
||||
ch = forum_cls()
|
||||
assert adapter._is_forum_parent(ch) is True
|
||||
|
||||
def test_type_value_15(self):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
ch = SimpleNamespace(type=15)
|
||||
assert adapter._is_forum_parent(ch) is True
|
||||
|
||||
def test_regular_channel_returns_false(self):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
ch = SimpleNamespace(type=0)
|
||||
assert adapter._is_forum_parent(ch) is False
|
||||
|
||||
def test_thread_returns_false(self):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
ch = SimpleNamespace(type=11) # public thread
|
||||
assert adapter._is_forum_parent(ch) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_forum_creates_thread_post():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
# thread object has no 'send' so _send_to_forum uses thread.thread
|
||||
thread_ch = SimpleNamespace(id=555, send=AsyncMock(return_value=SimpleNamespace(id=600)))
|
||||
thread = SimpleNamespace(
|
||||
id=555,
|
||||
message=SimpleNamespace(id=500),
|
||||
thread=thread_ch,
|
||||
)
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.name = "ideas"
|
||||
forum_channel.create_thread = AsyncMock(return_value=thread)
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: forum_channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await adapter.send("999", "Hello forum!")
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "500"
|
||||
forum_channel.create_thread.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_forum_sends_remaining_chunks():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
# Force a small max message length so the message splits
|
||||
adapter.MAX_MESSAGE_LENGTH = 20
|
||||
|
||||
chunk_msg_1 = SimpleNamespace(id=500)
|
||||
chunk_msg_2 = SimpleNamespace(id=501)
|
||||
thread_ch = SimpleNamespace(
|
||||
id=555,
|
||||
send=AsyncMock(return_value=chunk_msg_2),
|
||||
)
|
||||
# thread object has no 'send' so _send_to_forum uses thread.thread
|
||||
thread = SimpleNamespace(
|
||||
id=555,
|
||||
message=chunk_msg_1,
|
||||
thread=thread_ch,
|
||||
)
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.name = "ideas"
|
||||
forum_channel.create_thread = AsyncMock(return_value=thread)
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: forum_channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await adapter.send("999", "A" * 50)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "500"
|
||||
# Should have sent at least one follow-up chunk
|
||||
assert thread_ch.send.await_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_forum_create_thread_failure():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.name = "ideas"
|
||||
forum_channel.create_thread = AsyncMock(side_effect=Exception("rate limited"))
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: forum_channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await adapter.send("999", "Hello forum!")
|
||||
|
||||
assert result.success is False
|
||||
assert "rate limited" in result.error
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forum follow-up chunk failure reporting + media on forum paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_forum_follow_up_chunk_failures_collected_as_warnings():
|
||||
"""Partial-send chunk failures surface in raw_response['warnings']."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter.MAX_MESSAGE_LENGTH = 20
|
||||
|
||||
chunk_msg_1 = SimpleNamespace(id=500)
|
||||
# Every follow-up chunk fails — we should collect a warning per failure
|
||||
thread_ch = SimpleNamespace(
|
||||
id=555,
|
||||
send=AsyncMock(side_effect=Exception("rate limited")),
|
||||
)
|
||||
thread = SimpleNamespace(id=555, message=chunk_msg_1, thread=thread_ch)
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.name = "ideas"
|
||||
forum_channel.create_thread = AsyncMock(return_value=thread)
|
||||
adapter._client = SimpleNamespace(
|
||||
get_channel=lambda _chat_id: forum_channel,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
# Long enough to produce multiple chunks
|
||||
result = await adapter.send("999", "A" * 60)
|
||||
|
||||
# Starter message (first chunk) was delivered via create_thread, so send is
|
||||
# successful overall — but follow-up chunks all failed and are reported.
|
||||
assert result.success is True
|
||||
assert result.message_id == "500"
|
||||
warnings = (result.raw_response or {}).get("warnings") or []
|
||||
assert len(warnings) >= 1
|
||||
assert all("rate limited" in w for w in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forum_post_file_creates_thread_with_attachment():
|
||||
"""_forum_post_file routes file-bearing sends to create_thread with file kwarg."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
thread_ch = SimpleNamespace(id=777, send=AsyncMock())
|
||||
thread = SimpleNamespace(id=777, message=SimpleNamespace(id=800), thread=thread_ch)
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.name = "ideas"
|
||||
forum_channel.create_thread = AsyncMock(return_value=thread)
|
||||
|
||||
# discord.File is a real class; build a MagicMock that looks like one
|
||||
fake_file = SimpleNamespace(filename="photo.png")
|
||||
|
||||
result = await adapter._forum_post_file(
|
||||
forum_channel,
|
||||
content="here is a photo",
|
||||
file=fake_file,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "800"
|
||||
forum_channel.create_thread.assert_awaited_once()
|
||||
call_kwargs = forum_channel.create_thread.await_args.kwargs
|
||||
assert call_kwargs["file"] is fake_file
|
||||
assert call_kwargs["content"] == "here is a photo"
|
||||
# Thread name derived from content's first line
|
||||
assert call_kwargs["name"] == "here is a photo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forum_post_file_uses_filename_when_no_content():
|
||||
"""Thread name falls back to file.filename when no content is provided."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
thread = SimpleNamespace(id=1, message=SimpleNamespace(id=2), thread=SimpleNamespace(id=1, send=AsyncMock()))
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 10
|
||||
forum_channel.name = "forum"
|
||||
forum_channel.create_thread = AsyncMock(return_value=thread)
|
||||
|
||||
fake_file = SimpleNamespace(filename="voice-message.ogg")
|
||||
result = await adapter._forum_post_file(forum_channel, content="", file=fake_file)
|
||||
|
||||
assert result.success is True
|
||||
call_kwargs = forum_channel.create_thread.await_args.kwargs
|
||||
# Content was empty → thread name derived from filename
|
||||
assert call_kwargs["name"] == "voice-message.ogg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forum_post_file_creation_failure():
|
||||
"""_forum_post_file returns a failed SendResult when create_thread raises."""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
forum_channel = _discord_mod.ForumChannel()
|
||||
forum_channel.id = 999
|
||||
forum_channel.create_thread = AsyncMock(side_effect=Exception("missing perms"))
|
||||
|
||||
result = await adapter._forum_post_file(
|
||||
forum_channel,
|
||||
content="hi",
|
||||
file=SimpleNamespace(filename="x.png"),
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "missing perms" in (result.error or "")
|
||||
|
||||
@@ -11,28 +11,66 @@ from gateway.config import PlatformConfig
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
# Real discord is installed — nothing to do.
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
if sys.modules.get("discord") is None:
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
# Lightweight mock for app_commands.Group and Command used by
|
||||
# _register_skill_group.
|
||||
class _FakeGroup:
|
||||
def __init__(self, *, name, description, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parent = parent
|
||||
self._children: dict[str, object] = {}
|
||||
if parent is not None:
|
||||
parent.add_command(self)
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
def add_command(self, cmd):
|
||||
self._children[cmd.name] = cmd
|
||||
|
||||
class _FakeCommand:
|
||||
def __init__(self, *, name, description, callback, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.callback = callback
|
||||
self.parent = parent
|
||||
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
autocomplete=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
Group=_FakeGroup,
|
||||
Command=_FakeCommand,
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules["discord"] = discord_mod
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
# Whether we just installed the mock OR another test module installed
|
||||
# it first via its own _ensure_discord_mock, force the decorators we
|
||||
# need onto discord.app_commands — the flat /skill command uses
|
||||
# @app_commands.autocomplete and not every other mock stub exposes it.
|
||||
_app = getattr(sys.modules["discord"], "app_commands", None)
|
||||
if _app is not None and not hasattr(_app, "autocomplete"):
|
||||
try:
|
||||
_app.autocomplete = lambda **kwargs: (lambda fn: fn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
@@ -51,6 +89,12 @@ class FakeTree:
|
||||
|
||||
return decorator
|
||||
|
||||
def add_command(self, cmd):
|
||||
self.commands[cmd.name] = cmd
|
||||
|
||||
def get_commands(self):
|
||||
return [SimpleNamespace(name=n) for n in self.commands]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
@@ -87,6 +131,74 @@ async def test_registers_native_thread_slash_command(adapter):
|
||||
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_native_restart_slash_command(adapter):
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
assert "restart" in adapter._client.tree.commands
|
||||
|
||||
interaction = SimpleNamespace()
|
||||
await adapter._client.tree.commands["restart"](interaction)
|
||||
|
||||
adapter._run_simple_slash.assert_awaited_once_with(
|
||||
interaction,
|
||||
"/restart",
|
||||
"Restart requested~",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-registration from COMMAND_REGISTRY
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_registers_missing_gateway_commands(adapter):
|
||||
"""Commands in COMMAND_REGISTRY that aren't explicitly registered should
|
||||
be auto-registered by the dynamic catch-all block."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree_names = set(adapter._client.tree.commands.keys())
|
||||
|
||||
# These commands are gateway-available but were not in the original
|
||||
# hardcoded registration list — they should be auto-registered.
|
||||
expected_auto = {"debug", "yolo", "reload", "profile"}
|
||||
for name in expected_auto:
|
||||
assert name in tree_names, f"/{name} should be auto-registered on Discord"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_registered_command_dispatches_correctly(adapter):
|
||||
"""Auto-registered commands should dispatch via _run_simple_slash."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
# /debug has no args — test parameterless dispatch
|
||||
debug_cmd = adapter._client.tree.commands["debug"]
|
||||
interaction = SimpleNamespace()
|
||||
adapter._run_simple_slash.reset_mock()
|
||||
await debug_cmd.callback(interaction)
|
||||
adapter._run_simple_slash.assert_awaited_once_with(interaction, "/debug")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_registered_command_with_args(adapter):
|
||||
"""Auto-registered commands with args_hint should accept an optional args param."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
# /branch has args_hint="[name]" — test dispatch with args
|
||||
branch_cmd = adapter._client.tree.commands["branch"]
|
||||
interaction = SimpleNamespace()
|
||||
adapter._run_simple_slash.reset_mock()
|
||||
await branch_cmd.callback(interaction, args="my-branch")
|
||||
adapter._run_simple_slash.assert_awaited_once_with(
|
||||
interaction, "/branch my-branch"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _handle_thread_create_slash — success, session dispatch, failure
|
||||
# ------------------------------------------------------------------
|
||||
@@ -289,6 +401,8 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
|
||||
message = SimpleNamespace(
|
||||
content="Hello world, how are you?",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
channel=SimpleNamespace(send=AsyncMock()),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
@@ -300,6 +414,48 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
|
||||
assert call_kwargs["auto_archive_duration"] == 1440
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):
|
||||
"""Thread names must not contain raw <@id>, <@&id>, or <#id> markers.
|
||||
|
||||
Regression guard for #6336 — previously a message like
|
||||
``<@&1490963422786093149> help`` would spawn a thread literally
|
||||
named ``<@&1490963422786093149> help``.
|
||||
"""
|
||||
thread = SimpleNamespace(id=999, name="help")
|
||||
message = SimpleNamespace(
|
||||
content="<@&1490963422786093149> <@555> please help <#123>",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
channel=SimpleNamespace(send=AsyncMock()),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
await adapter._auto_create_thread(message)
|
||||
|
||||
name = message.create_thread.await_args[1]["name"]
|
||||
assert "<@" not in name, f"role/user mention leaked: {name!r}"
|
||||
assert "<#" not in name, f"channel mention leaked: {name!r}"
|
||||
assert name == "please help"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_falls_back_to_hermes_when_only_mentions(adapter):
|
||||
"""If a message contains only mention syntax, the stripped content is
|
||||
empty — fall back to the 'Hermes' default rather than ''."""
|
||||
thread = SimpleNamespace(id=999, name="Hermes")
|
||||
message = SimpleNamespace(
|
||||
content="<@&1490963422786093149>",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
channel=SimpleNamespace(send=AsyncMock()),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
await adapter._auto_create_thread(message)
|
||||
|
||||
name = message.create_thread.await_args[1]["name"]
|
||||
assert name == "Hermes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
long_text = "a" * 200
|
||||
@@ -307,6 +463,8 @@ async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
message = SimpleNamespace(
|
||||
content=long_text,
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
channel=SimpleNamespace(send=AsyncMock()),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
@@ -318,10 +476,33 @@ async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_returns_none_on_failure(adapter):
|
||||
async def test_auto_create_thread_falls_back_to_seed_message(adapter):
|
||||
thread = SimpleNamespace(id=555, name="Hello")
|
||||
seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=thread))
|
||||
message = SimpleNamespace(
|
||||
content="Hello",
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
channel=SimpleNamespace(send=AsyncMock(return_value=seed_message)),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
assert result is thread
|
||||
message.channel.send.assert_awaited_once_with("🧵 Thread created by Hermes: **Hello**")
|
||||
seed_message.create_thread.assert_awaited_once_with(
|
||||
name="Hello",
|
||||
auto_archive_duration=1440,
|
||||
reason="Auto-threaded from mention by Jezza",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(adapter):
|
||||
message = SimpleNamespace(
|
||||
content="Hello",
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
channel=SimpleNamespace(send=AsyncMock(side_effect=RuntimeError("send failed"))),
|
||||
author=SimpleNamespace(display_name="Jezza"),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
@@ -498,3 +679,207 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
|
||||
|
||||
import os
|
||||
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /skill command registration (flat + autocomplete)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_register_skill_command_is_flat_not_nested(adapter):
|
||||
"""_register_skill_group should register a single flat ``/skill`` command.
|
||||
|
||||
The older layout nested categories as subcommand groups under ``/skill``.
|
||||
That registered as one giant command whose serialized payload exceeded
|
||||
Discord's 8KB per-command limit with the default skill catalog. The
|
||||
flat layout sidesteps the limit — autocomplete options are fetched
|
||||
dynamically by Discord and don't count against the registration budget.
|
||||
"""
|
||||
mock_categories = {
|
||||
"creative": [
|
||||
("ascii-art", "Generate ASCII art", "/ascii-art"),
|
||||
("excalidraw", "Hand-drawn diagrams", "/excalidraw"),
|
||||
],
|
||||
"media": [
|
||||
("gif-search", "Search for GIFs", "/gif-search"),
|
||||
],
|
||||
}
|
||||
mock_uncategorized = [
|
||||
("dogfood", "Exploratory QA testing", "/dogfood"),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(mock_categories, mock_uncategorized, 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree = adapter._client.tree
|
||||
assert "skill" in tree.commands, "Expected /skill command to be registered"
|
||||
skill_cmd = tree.commands["skill"]
|
||||
assert skill_cmd.name == "skill"
|
||||
# Flat command — NOT a Group — so it has no _children of category subgroups
|
||||
assert not hasattr(skill_cmd, "_children") or not getattr(skill_cmd, "_children", {}), (
|
||||
"Flat /skill command should not have subcommand children"
|
||||
)
|
||||
|
||||
|
||||
def test_register_skill_command_empty_skills_no_command(adapter):
|
||||
"""No /skill command should be registered when there are zero skills."""
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=({}, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree = adapter._client.tree
|
||||
assert "skill" not in tree.commands
|
||||
|
||||
|
||||
def test_register_skill_command_callback_dispatches_by_name(adapter):
|
||||
"""The /skill callback should look up the skill by ``name`` and
|
||||
dispatch via ``_run_simple_slash`` with the real command key.
|
||||
"""
|
||||
mock_categories = {
|
||||
"media": [
|
||||
("gif-search", "Search for GIFs", "/gif-search"),
|
||||
],
|
||||
}
|
||||
mock_uncategorized = [
|
||||
("dogfood", "QA testing", "/dogfood"),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(mock_categories, mock_uncategorized, 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
skill_cmd = adapter._client.tree.commands["skill"]
|
||||
assert skill_cmd.callback is not None
|
||||
|
||||
# Stub out _run_simple_slash so we can verify the dispatched text.
|
||||
dispatched: list[str] = []
|
||||
|
||||
async def fake_run(_interaction, text):
|
||||
dispatched.append(text)
|
||||
|
||||
adapter._run_simple_slash = fake_run
|
||||
|
||||
import asyncio
|
||||
|
||||
fake_interaction = SimpleNamespace()
|
||||
# gif-search → /gif-search with no args
|
||||
asyncio.run(skill_cmd.callback(fake_interaction, name="gif-search"))
|
||||
# dogfood with args
|
||||
asyncio.run(skill_cmd.callback(fake_interaction, name="dogfood", args="my test"))
|
||||
|
||||
assert dispatched == ["/gif-search", "/dogfood my test"]
|
||||
|
||||
|
||||
def test_register_skill_command_handles_unknown_skill_gracefully(adapter):
|
||||
"""Passing a name that isn't a registered skill should respond with
|
||||
an ephemeral error message, NOT crash the callback.
|
||||
"""
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=({"media": [("gif-search", "GIFs", "/gif-search")]}, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
skill_cmd = adapter._client.tree.commands["skill"]
|
||||
|
||||
sent: list[dict] = []
|
||||
|
||||
async def fake_send(text, ephemeral=False):
|
||||
sent.append({"text": text, "ephemeral": ephemeral})
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
response=SimpleNamespace(send_message=fake_send),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
asyncio.run(skill_cmd.callback(interaction, name="does-not-exist"))
|
||||
|
||||
assert len(sent) == 1
|
||||
assert "Unknown skill" in sent[0]["text"]
|
||||
assert "does-not-exist" in sent[0]["text"]
|
||||
assert sent[0]["ephemeral"] is True
|
||||
|
||||
|
||||
def test_register_skill_command_payload_fits_discord_8kb_limit(adapter):
|
||||
"""The /skill command registration payload must stay under Discord's
|
||||
~8000-byte per-command limit even with a large skill catalog.
|
||||
|
||||
This is the regression guard for #11321 / #10259. Simulates 500 skills
|
||||
(20 categories × 25 — the hard cap per category in the collector) and
|
||||
confirms the serialized command still fits. Autocomplete options are
|
||||
not part of this payload, so the budget is essentially constant.
|
||||
"""
|
||||
import json
|
||||
|
||||
# Simulate the largest catalog the collector will ever produce:
|
||||
# 20 categories × 25 skills each, with verbose 100-char descriptions.
|
||||
large_categories: dict[str, list[tuple[str, str, str]]] = {}
|
||||
long_desc = "A verbose description padded to approximately 100 chars " + "." * 42
|
||||
for i in range(20):
|
||||
cat = f"cat{i:02d}"
|
||||
large_categories[cat] = [
|
||||
(f"skill-{i:02d}-{j:02d}", long_desc, f"/skill-{i:02d}-{j:02d}")
|
||||
for j in range(25)
|
||||
]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(large_categories, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
skill_cmd = adapter._client.tree.commands["skill"]
|
||||
# Approximate the serialized registration payload (name + description only).
|
||||
# Autocomplete options are NOT registered — they're fetched dynamically.
|
||||
payload = json.dumps({
|
||||
"name": skill_cmd.name,
|
||||
"description": skill_cmd.description,
|
||||
"options": [
|
||||
{"name": "name", "description": "Which skill to run", "type": 3, "required": True},
|
||||
{"name": "args", "description": "Optional arguments for the skill", "type": 3, "required": False},
|
||||
],
|
||||
})
|
||||
assert len(payload) < 500, (
|
||||
f"Flat /skill command payload is ~{len(payload)} bytes — the whole "
|
||||
f"point of this design is that it stays small regardless of skill count"
|
||||
)
|
||||
|
||||
|
||||
def test_register_skill_command_autocomplete_filters_by_name_and_description(adapter):
|
||||
"""The autocomplete callback should match on both skill name and
|
||||
description so the user can search by either.
|
||||
"""
|
||||
mock_categories = {
|
||||
"ocr": [
|
||||
("ocr-and-documents", "Extract text from PDFs and scanned documents", "/ocr-and-documents"),
|
||||
],
|
||||
"media": [
|
||||
("gif-search", "Search and download GIFs from Tenor", "/gif-search"),
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
return_value=(mock_categories, [], 0),
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
skill_cmd = adapter._client.tree.commands["skill"]
|
||||
# The callback has been wrapped with @autocomplete(name=...) — in our mock
|
||||
# the decorator is pass-through, so we inspect the closed-over list by
|
||||
# invoking the registered autocomplete function directly through the
|
||||
# test API. Since the mock doesn't preserve the autocomplete binding,
|
||||
# we re-derive the filter by building the same entries list.
|
||||
#
|
||||
# What we CAN verify at this layer: the callback dispatches correctly
|
||||
# (covered in other tests). The autocomplete filter itself is exercised
|
||||
# via direct function call in the real-discord integration path.
|
||||
assert skill_cmd.callback is not None
|
||||
|
||||
|
||||
@@ -283,6 +283,48 @@ def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
|
||||
# ── _get_dm_topic_info ──
|
||||
|
||||
|
||||
def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path):
|
||||
"""Failed writes should leave the original config.yaml intact."""
|
||||
import yaml
|
||||
|
||||
config_data = {
|
||||
"platforms": {
|
||||
"telegram": {
|
||||
"extra": {
|
||||
"dm_topics": [
|
||||
{
|
||||
"chat_id": 111,
|
||||
"topics": [
|
||||
{"name": "General", "icon_color": 123},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config_file = tmp_path / ".hermes" / "config.yaml"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
original_text = yaml.dump(config_data)
|
||||
config_file.write_text(original_text, encoding="utf-8")
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
def fail_dump(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with patch.object(Path, "home", return_value=tmp_path), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \
|
||||
patch("yaml.dump", side_effect=fail_dump):
|
||||
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
||||
|
||||
assert config_file.read_text(encoding="utf-8") == original_text
|
||||
result = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
||||
assert "thread_id" not in topics[0]
|
||||
|
||||
|
||||
def test_get_dm_topic_info_finds_cached_topic():
|
||||
"""Should return topic config when thread_id is in cache."""
|
||||
adapter = _make_adapter([
|
||||
@@ -645,3 +687,54 @@ def test_group_topic_chat_id_int_string_coercion():
|
||||
|
||||
assert event.auto_skill == "hermes-agent-dev"
|
||||
assert event.source.chat_topic == "Dev"
|
||||
|
||||
|
||||
# ── _build_message_event: from_user=None fallback in DMs ──
|
||||
|
||||
|
||||
def test_build_message_event_dm_from_user_none_falls_back_to_chat_id():
|
||||
"""When from_user is None in a DM, user_id should fall back to chat.id."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter()
|
||||
msg = _make_mock_message(chat_id=12345, user_id=42, user_name="Alice")
|
||||
# Simulate from_user being None (edge case on fresh restart / forwarded msg)
|
||||
msg.from_user = None
|
||||
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
# Should fall back to chat.id since chat_type is "dm"
|
||||
assert event.source.user_id == "12345"
|
||||
assert event.source.user_name == "Alice" # falls back to chat.full_name
|
||||
|
||||
|
||||
def test_build_message_event_group_from_user_none_stays_none():
|
||||
"""When from_user is None in a group, user_id should remain None."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter()
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP,
|
||||
user_id=42, user_name="Alice"
|
||||
)
|
||||
msg.from_user = None
|
||||
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
# Groups should NOT fall back — anonymous senders stay None
|
||||
assert event.source.user_id is None
|
||||
assert event.source.user_name is None
|
||||
|
||||
|
||||
def test_build_message_event_dm_from_user_present_uses_user():
|
||||
"""When from_user is present in a DM, it should be used (no fallback)."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter()
|
||||
msg = _make_mock_message(chat_id=12345, user_id=99999, user_name="Bob")
|
||||
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
# Normal case — from_user is used directly
|
||||
assert event.source.user_id == "99999"
|
||||
assert event.source.user_name == "Bob"
|
||||
|
||||
460
tests/gateway/test_duplicate_reply_suppression.py
Normal file
460
tests/gateway/test_duplicate_reply_suppression.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""Tests for duplicate reply suppression across the gateway stack.
|
||||
|
||||
Covers four fix paths:
|
||||
1. base.py: stale response suppressed when interrupt_event is set and a
|
||||
pending message exists (#8221 / #2483)
|
||||
2. run.py return path: only confirmed final streamed delivery suppresses
|
||||
the fallback final send; partial streamed output must not
|
||||
3. run.py queued-message path: first response is skipped only when the
|
||||
final response was actually streamed, not merely when partial output existed
|
||||
4. stream_consumer.py cancellation handler: only confirms final delivery
|
||||
when the best-effort send actually succeeds, not merely because partial
|
||||
content was sent earlier
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StubAdapter(BasePlatformAdapter):
|
||||
"""Minimal concrete adapter for testing."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(PlatformConfig(enabled=True, token="fake"), Platform.DISCORD)
|
||||
self.sent = []
|
||||
|
||||
async def connect(self):
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
return SendResult(success=True, message_id="msg1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
def _make_event(text="hello", chat_id="c1", user_id="u1"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm",
|
||||
user_id=user_id,
|
||||
),
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 1: base.py — stale response suppressed on interrupt (#8221)
|
||||
# ===================================================================
|
||||
|
||||
class TestBaseInterruptSuppression:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_response_suppressed_when_interrupted(self):
|
||||
"""When interrupt_event is set AND a pending message exists,
|
||||
base.py should suppress the stale response instead of sending it."""
|
||||
adapter = StubAdapter()
|
||||
|
||||
stale_response = "This is the stale answer to the first question."
|
||||
pending_response = "This is the answer to the second question."
|
||||
call_count = 0
|
||||
|
||||
async def fake_handler(event):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return stale_response
|
||||
return pending_response
|
||||
|
||||
adapter.set_message_handler(fake_handler)
|
||||
|
||||
event_a = _make_event(text="first question")
|
||||
session_key = build_session_key(event_a.source)
|
||||
|
||||
# Simulate: message A is being processed, message B arrives
|
||||
# The interrupt event is set and B is in pending_messages
|
||||
interrupt_event = asyncio.Event()
|
||||
interrupt_event.set()
|
||||
adapter._active_sessions[session_key] = interrupt_event
|
||||
|
||||
event_b = _make_event(text="second question")
|
||||
adapter._pending_messages[session_key] = event_b
|
||||
|
||||
await adapter._process_message_background(event_a, session_key)
|
||||
|
||||
# The stale response should NOT have been sent.
|
||||
stale_sends = [s for s in adapter.sent if s["content"] == stale_response]
|
||||
assert len(stale_sends) == 0, (
|
||||
f"Stale response was sent {len(stale_sends)} time(s) — should be suppressed"
|
||||
)
|
||||
# The pending message's response SHOULD have been sent.
|
||||
pending_sends = [s for s in adapter.sent if s["content"] == pending_response]
|
||||
assert len(pending_sends) == 1, "Pending message response should be sent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_not_suppressed_without_interrupt(self):
|
||||
"""Normal case: no interrupt, response should be sent."""
|
||||
adapter = StubAdapter()
|
||||
|
||||
async def fake_handler(event):
|
||||
return "Normal response"
|
||||
|
||||
adapter.set_message_handler(fake_handler)
|
||||
event = _make_event()
|
||||
session_key = build_session_key(event.source)
|
||||
|
||||
await adapter._process_message_background(event, session_key)
|
||||
|
||||
assert any(s["content"] == "Normal response" for s in adapter.sent)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_not_suppressed_with_interrupt_but_no_pending(self):
|
||||
"""Interrupt event set but no pending message (race already resolved) —
|
||||
response should still be sent."""
|
||||
adapter = StubAdapter()
|
||||
|
||||
async def fake_handler(event):
|
||||
return "Valid response"
|
||||
|
||||
adapter.set_message_handler(fake_handler)
|
||||
event = _make_event()
|
||||
session_key = build_session_key(event.source)
|
||||
|
||||
# Set interrupt but no pending message
|
||||
interrupt_event = asyncio.Event()
|
||||
interrupt_event.set()
|
||||
adapter._active_sessions[session_key] = interrupt_event
|
||||
|
||||
await adapter._process_message_background(event, session_key)
|
||||
|
||||
assert any(s["content"] == "Valid response" for s in adapter.sent)
|
||||
|
||||
|
||||
# Test 2: run.py — partial streamed output must not suppress final send
|
||||
# ===================================================================
|
||||
|
||||
class TestOnlyFinalStreamDeliverySuppressesFinalSend:
|
||||
"""The gateway should suppress the fallback final send only when the
|
||||
stream consumer confirmed the final assistant reply was delivered.
|
||||
|
||||
Partial streamed output is not enough. If only already_sent=True,
|
||||
the fallback final send must still happen so Telegram users don't lose
|
||||
the real answer."""
|
||||
|
||||
def _make_mock_stream_consumer(self, already_sent=False, final_response_sent=False):
|
||||
sc = SimpleNamespace(
|
||||
already_sent=already_sent,
|
||||
final_response_sent=final_response_sent,
|
||||
)
|
||||
return sc
|
||||
|
||||
def test_partial_stream_output_does_not_set_already_sent(self):
|
||||
"""already_sent=True alone must NOT suppress final delivery."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False)
|
||||
response = {"final_response": "text", "response_previewed": False}
|
||||
|
||||
if sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_final = response.get("final_response") or ""
|
||||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||||
_streamed = bool(sc and getattr(sc, "final_response_sent", False))
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
response["already_sent"] = True
|
||||
|
||||
assert "already_sent" not in response
|
||||
|
||||
def test_already_sent_not_set_when_nothing_sent(self):
|
||||
"""When stream consumer hasn't sent anything, already_sent should
|
||||
not be set on the response."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=False)
|
||||
response = {"final_response": "text", "response_previewed": False}
|
||||
|
||||
if sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_final = response.get("final_response") or ""
|
||||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||||
_streamed = bool(sc and getattr(sc, "final_response_sent", False))
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
response["already_sent"] = True
|
||||
|
||||
assert "already_sent" not in response
|
||||
|
||||
def test_already_sent_set_on_final_response_sent(self):
|
||||
"""final_response_sent=True should suppress duplicate final sends."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=True)
|
||||
response = {"final_response": "text"}
|
||||
|
||||
if sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_final = response.get("final_response") or ""
|
||||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||||
_streamed = bool(sc and getattr(sc, "final_response_sent", False))
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
response["already_sent"] = True
|
||||
|
||||
assert response.get("already_sent") is True
|
||||
|
||||
def test_already_sent_not_set_on_failed_response(self):
|
||||
"""Failed responses should never be suppressed — user needs to see
|
||||
the error message even if streaming sent earlier partial output."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False)
|
||||
response = {"final_response": "Error: something broke", "failed": True}
|
||||
|
||||
if sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_final = response.get("final_response") or ""
|
||||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||||
_streamed = bool(sc and getattr(sc, "final_response_sent", False))
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
response["already_sent"] = True
|
||||
|
||||
assert "already_sent" not in response
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 2b: run.py — empty response never suppressed (#10xxx)
|
||||
# ===================================================================
|
||||
|
||||
class TestEmptyResponseNotSuppressed:
|
||||
"""When the model returns '(empty)' after tool calls (e.g. mimo-v2-pro
|
||||
going silent after web_search), the gateway must NOT suppress delivery
|
||||
even if the stream consumer sent intermediate text earlier.
|
||||
|
||||
Without this fix, the user sees partial streaming text ('Let me search
|
||||
for that') and then silence — the '(empty)' sentinel is swallowed by
|
||||
already_sent=True."""
|
||||
|
||||
def _make_mock_stream_consumer(self, already_sent=False, final_response_sent=False):
|
||||
return SimpleNamespace(
|
||||
already_sent=already_sent,
|
||||
final_response_sent=final_response_sent,
|
||||
)
|
||||
|
||||
def _apply_suppression_logic(self, response, sc):
|
||||
"""Reproduce the fixed logic from gateway/run.py return path."""
|
||||
if sc and isinstance(response, dict) and not response.get("failed"):
|
||||
_final = response.get("final_response") or ""
|
||||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||||
_streamed = bool(sc and getattr(sc, "final_response_sent", False))
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
response["already_sent"] = True
|
||||
|
||||
def test_empty_sentinel_not_suppressed_with_already_sent(self):
|
||||
"""'(empty)' final_response should NOT be suppressed even when
|
||||
streaming sent intermediate content."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True)
|
||||
response = {"final_response": "(empty)"}
|
||||
self._apply_suppression_logic(response, sc)
|
||||
assert "already_sent" not in response
|
||||
|
||||
def test_empty_string_not_suppressed_with_already_sent(self):
|
||||
"""Empty string final_response should NOT be suppressed."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True)
|
||||
response = {"final_response": ""}
|
||||
self._apply_suppression_logic(response, sc)
|
||||
assert "already_sent" not in response
|
||||
|
||||
def test_none_response_not_suppressed_with_already_sent(self):
|
||||
"""None final_response should NOT be suppressed."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True)
|
||||
response = {"final_response": None}
|
||||
self._apply_suppression_logic(response, sc)
|
||||
assert "already_sent" not in response
|
||||
|
||||
def test_real_response_still_suppressed_only_when_final_delivery_confirmed(self):
|
||||
"""Normal non-empty response should be suppressed only when the final
|
||||
response was actually streamed."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True)
|
||||
response = {"final_response": "Here are the search results..."}
|
||||
self._apply_suppression_logic(response, sc)
|
||||
assert response.get("already_sent") is True
|
||||
|
||||
def test_failed_empty_response_never_suppressed(self):
|
||||
"""Failed responses are never suppressed regardless of content."""
|
||||
sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True)
|
||||
response = {"final_response": "(empty)", "failed": True}
|
||||
self._apply_suppression_logic(response, sc)
|
||||
assert "already_sent" not in response
|
||||
|
||||
class TestQueuedMessageAlreadyStreamed:
|
||||
"""The queued-message path should skip the first response only when the
|
||||
final response was actually streamed."""
|
||||
|
||||
def _make_mock_sc(self, already_sent=False, final_response_sent=False):
|
||||
return SimpleNamespace(
|
||||
already_sent=already_sent,
|
||||
final_response_sent=final_response_sent,
|
||||
)
|
||||
|
||||
def test_queued_path_only_skips_send_when_final_response_was_streamed(self):
|
||||
"""Partial streamed output alone must not suppress the first response
|
||||
before the queued follow-up is processed."""
|
||||
_sc = self._make_mock_sc(already_sent=True, final_response_sent=False)
|
||||
|
||||
_already_streamed = bool(
|
||||
_sc and getattr(_sc, "final_response_sent", False)
|
||||
)
|
||||
|
||||
assert _already_streamed is False
|
||||
|
||||
def test_queued_path_detects_confirmed_final_stream_delivery(self):
|
||||
"""Confirmed final streamed delivery should skip the resend."""
|
||||
_sc = self._make_mock_sc(already_sent=True, final_response_sent=True)
|
||||
response = {"response_previewed": False}
|
||||
|
||||
_already_streamed = bool(
|
||||
(_sc and getattr(_sc, "final_response_sent", False))
|
||||
or bool(response.get("response_previewed"))
|
||||
)
|
||||
|
||||
assert _already_streamed is True
|
||||
|
||||
def test_queued_path_detects_previewed_response_delivery(self):
|
||||
"""A response already previewed via the adapter should not be resent
|
||||
before processing the queued follow-up."""
|
||||
_sc = self._make_mock_sc(already_sent=False, final_response_sent=False)
|
||||
response = {"response_previewed": True}
|
||||
|
||||
_already_streamed = bool(
|
||||
(_sc and getattr(_sc, "final_response_sent", False))
|
||||
or bool(response.get("response_previewed"))
|
||||
)
|
||||
|
||||
assert _already_streamed is True
|
||||
|
||||
def test_queued_path_sends_when_not_streamed(self):
|
||||
"""Nothing was streamed — first response should be sent before
|
||||
processing the queued message."""
|
||||
_sc = self._make_mock_sc(already_sent=False, final_response_sent=False)
|
||||
|
||||
_already_streamed = bool(
|
||||
_sc and getattr(_sc, "final_response_sent", False)
|
||||
)
|
||||
|
||||
assert _already_streamed is False
|
||||
|
||||
def test_queued_path_with_no_stream_consumer(self):
|
||||
"""No stream consumer at all (streaming disabled) — not streamed."""
|
||||
_sc = None
|
||||
|
||||
_already_streamed = bool(
|
||||
_sc and getattr(_sc, "final_response_sent", False)
|
||||
)
|
||||
|
||||
assert _already_streamed is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 4: stream_consumer.py — cancellation handler delivery confirmation
|
||||
# ===================================================================
|
||||
|
||||
class TestCancellationHandlerDeliveryConfirmation:
|
||||
"""The stream consumer's cancellation handler should only set
|
||||
final_response_sent when the best-effort send actually succeeds.
|
||||
Partial content (already_sent=True) alone must not promote to
|
||||
final_response_sent — that would suppress the gateway's fallback
|
||||
send even when the user never received the real answer."""
|
||||
|
||||
def test_partial_only_no_accumulated_stays_false(self):
|
||||
"""Cancelled after sending intermediate text, nothing accumulated.
|
||||
final_response_sent must stay False so the gateway fallback fires."""
|
||||
already_sent = True
|
||||
final_response_sent = False
|
||||
accumulated = ""
|
||||
message_id = None
|
||||
|
||||
_best_effort_ok = False
|
||||
if accumulated and message_id:
|
||||
_best_effort_ok = True # wouldn't enter
|
||||
if _best_effort_ok and not final_response_sent:
|
||||
final_response_sent = True
|
||||
|
||||
assert final_response_sent is False
|
||||
|
||||
def test_best_effort_succeeds_sets_true(self):
|
||||
"""When accumulated content exists and best-effort send succeeds,
|
||||
final_response_sent should become True."""
|
||||
already_sent = True
|
||||
final_response_sent = False
|
||||
accumulated = "Here are the search results..."
|
||||
message_id = "msg_123"
|
||||
|
||||
_best_effort_ok = False
|
||||
if accumulated and message_id:
|
||||
_best_effort_ok = True # simulating successful _send_or_edit
|
||||
if _best_effort_ok and not final_response_sent:
|
||||
final_response_sent = True
|
||||
|
||||
assert final_response_sent is True
|
||||
|
||||
def test_best_effort_fails_stays_false(self):
|
||||
"""When best-effort send fails (flood control, network), the
|
||||
gateway fallback must deliver the response."""
|
||||
already_sent = True
|
||||
final_response_sent = False
|
||||
accumulated = "Here are the search results..."
|
||||
message_id = "msg_123"
|
||||
|
||||
_best_effort_ok = False
|
||||
if accumulated and message_id:
|
||||
_best_effort_ok = False # simulating failed _send_or_edit
|
||||
if _best_effort_ok and not final_response_sent:
|
||||
final_response_sent = True
|
||||
|
||||
assert final_response_sent is False
|
||||
|
||||
def test_preserves_existing_true(self):
|
||||
"""If final_response_sent was already True before cancellation,
|
||||
it must remain True regardless."""
|
||||
already_sent = True
|
||||
final_response_sent = True
|
||||
accumulated = ""
|
||||
message_id = None
|
||||
|
||||
_best_effort_ok = False
|
||||
if accumulated and message_id:
|
||||
pass
|
||||
if _best_effort_ok and not final_response_sent:
|
||||
final_response_sent = True
|
||||
|
||||
assert final_response_sent is True
|
||||
|
||||
def test_old_behavior_would_have_promoted_partial(self):
|
||||
"""Verify the old code would have incorrectly promoted
|
||||
already_sent to final_response_sent even with no accumulated
|
||||
content — proving the bug existed."""
|
||||
already_sent = True
|
||||
final_response_sent = False
|
||||
|
||||
# OLD cancellation handler logic:
|
||||
if already_sent:
|
||||
final_response_sent = True
|
||||
|
||||
assert final_response_sent is True # the bug: partial promoted to final
|
||||
@@ -25,14 +25,6 @@ from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
|
||||
class TestPlatformEnum(unittest.TestCase):
|
||||
"""Verify EMAIL is in the Platform enum."""
|
||||
|
||||
def test_email_in_platform_enum(self):
|
||||
from gateway.config import Platform
|
||||
self.assertEqual(Platform.EMAIL.value, "email")
|
||||
|
||||
|
||||
class TestConfigEnvOverrides(unittest.TestCase):
|
||||
"""Verify email config is loaded from environment variables."""
|
||||
|
||||
@@ -72,20 +64,6 @@ class TestConfigEnvOverrides(unittest.TestCase):
|
||||
_apply_env_overrides(config)
|
||||
self.assertNotIn(Platform.EMAIL, config.platforms)
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"EMAIL_ADDRESS": "hermes@test.com",
|
||||
"EMAIL_PASSWORD": "secret",
|
||||
"EMAIL_IMAP_HOST": "imap.test.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
}, clear=False)
|
||||
def test_email_in_connected_platforms(self):
|
||||
from gateway.config import GatewayConfig, Platform, _apply_env_overrides
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
connected = config.get_connected_platforms()
|
||||
self.assertIn(Platform.EMAIL, connected)
|
||||
|
||||
|
||||
class TestCheckRequirements(unittest.TestCase):
|
||||
"""Verify check_email_requirements function."""
|
||||
|
||||
@@ -257,121 +235,6 @@ class TestExtractAttachments(unittest.TestCase):
|
||||
mock_cache.assert_called_once()
|
||||
|
||||
|
||||
class TestAuthorizationMaps(unittest.TestCase):
|
||||
"""Verify email is in authorization maps in gateway/run.py."""
|
||||
|
||||
def test_email_in_adapter_factory(self):
|
||||
"""Email adapter creation branch should exist."""
|
||||
import gateway.run
|
||||
import inspect
|
||||
source = inspect.getsource(gateway.run.GatewayRunner._create_adapter)
|
||||
self.assertIn("Platform.EMAIL", source)
|
||||
|
||||
def test_email_in_allowed_users_map(self):
|
||||
"""EMAIL_ALLOWED_USERS should be in platform_env_map."""
|
||||
import gateway.run
|
||||
import inspect
|
||||
source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized)
|
||||
self.assertIn("EMAIL_ALLOWED_USERS", source)
|
||||
|
||||
def test_email_in_allow_all_map(self):
|
||||
"""EMAIL_ALLOW_ALL_USERS should be in platform_allow_all_map."""
|
||||
import gateway.run
|
||||
import inspect
|
||||
source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized)
|
||||
self.assertIn("EMAIL_ALLOW_ALL_USERS", source)
|
||||
|
||||
|
||||
class TestSendMessageToolRouting(unittest.TestCase):
|
||||
"""Verify email routing in send_message_tool."""
|
||||
|
||||
def test_email_in_platform_map(self):
|
||||
import tools.send_message_tool as smt
|
||||
import inspect
|
||||
source = inspect.getsource(smt._handle_send)
|
||||
self.assertIn('"email"', source)
|
||||
|
||||
def test_send_to_platform_has_email_branch(self):
|
||||
import tools.send_message_tool as smt
|
||||
import inspect
|
||||
source = inspect.getsource(smt._send_to_platform)
|
||||
self.assertIn("Platform.EMAIL", source)
|
||||
|
||||
|
||||
class TestCronDelivery(unittest.TestCase):
|
||||
"""Verify email in cron scheduler platform_map."""
|
||||
|
||||
def test_email_in_cron_platform_map(self):
|
||||
import cron.scheduler
|
||||
import inspect
|
||||
source = inspect.getsource(cron.scheduler)
|
||||
self.assertIn('"email"', source)
|
||||
|
||||
|
||||
class TestToolset(unittest.TestCase):
|
||||
"""Verify email toolset is registered."""
|
||||
|
||||
def test_email_toolset_exists(self):
|
||||
from toolsets import TOOLSETS
|
||||
self.assertIn("hermes-email", TOOLSETS)
|
||||
|
||||
def test_email_in_gateway_toolset(self):
|
||||
from toolsets import TOOLSETS
|
||||
includes = TOOLSETS["hermes-gateway"]["includes"]
|
||||
self.assertIn("hermes-email", includes)
|
||||
|
||||
|
||||
class TestPlatformHints(unittest.TestCase):
|
||||
"""Verify email platform hint is registered."""
|
||||
|
||||
def test_email_in_platform_hints(self):
|
||||
from agent.prompt_builder import PLATFORM_HINTS
|
||||
self.assertIn("email", PLATFORM_HINTS)
|
||||
self.assertIn("email", PLATFORM_HINTS["email"].lower())
|
||||
|
||||
|
||||
class TestChannelDirectory(unittest.TestCase):
|
||||
"""Verify email in channel directory session-based discovery."""
|
||||
|
||||
def test_email_in_session_discovery(self):
|
||||
from gateway.config import Platform
|
||||
# Verify email is a Platform enum member — the dynamic loop in
|
||||
# build_channel_directory iterates all Platform members, so email
|
||||
# is included automatically as long as it's in the enum.
|
||||
email_values = [p.value for p in Platform]
|
||||
self.assertIn("email", email_values)
|
||||
|
||||
|
||||
class TestGatewaySetup(unittest.TestCase):
|
||||
"""Verify email in gateway setup wizard."""
|
||||
|
||||
def test_email_in_platforms_list(self):
|
||||
from hermes_cli.gateway import _PLATFORMS
|
||||
keys = [p["key"] for p in _PLATFORMS]
|
||||
self.assertIn("email", keys)
|
||||
|
||||
def test_email_has_setup_vars(self):
|
||||
from hermes_cli.gateway import _PLATFORMS
|
||||
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
|
||||
var_names = [v["name"] for v in email_platform["vars"]]
|
||||
self.assertIn("EMAIL_ADDRESS", var_names)
|
||||
self.assertIn("EMAIL_PASSWORD", var_names)
|
||||
self.assertIn("EMAIL_IMAP_HOST", var_names)
|
||||
self.assertIn("EMAIL_SMTP_HOST", var_names)
|
||||
|
||||
|
||||
class TestEnvExample(unittest.TestCase):
|
||||
"""Verify .env.example has email config."""
|
||||
|
||||
def test_env_example_has_email_vars(self):
|
||||
env_path = Path(__file__).resolve().parents[2] / ".env.example"
|
||||
content = env_path.read_text()
|
||||
self.assertIn("EMAIL_ADDRESS", content)
|
||||
self.assertIn("EMAIL_PASSWORD", content)
|
||||
self.assertIn("EMAIL_IMAP_HOST", content)
|
||||
self.assertIn("EMAIL_SMTP_HOST", content)
|
||||
|
||||
|
||||
class TestDispatchMessage(unittest.TestCase):
|
||||
"""Test email message dispatch logic."""
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -53,7 +53,6 @@ def _make_runner():
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
@@ -97,13 +96,7 @@ def test_turn_route_injects_priority_processing_without_changing_runtime():
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
@@ -123,13 +116,7 @@ def test_turn_route_skips_priority_processing_for_unsupported_models():
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
|
||||
assert route["request_overrides"] is None
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.base import ProcessingOutcome
|
||||
|
||||
try:
|
||||
import lark_oapi
|
||||
_HAS_LARK_OAPI = True
|
||||
@@ -29,13 +31,6 @@ def _mock_event_dispatcher_builder(mock_handler_class):
|
||||
return mock_builder
|
||||
|
||||
|
||||
class TestPlatformEnum(unittest.TestCase):
|
||||
def test_feishu_in_platform_enum(self):
|
||||
from gateway.config import Platform
|
||||
|
||||
self.assertEqual(Platform.FEISHU.value, "feishu")
|
||||
|
||||
|
||||
class TestConfigEnvOverrides(unittest.TestCase):
|
||||
@patch.dict(os.environ, {
|
||||
"FEISHU_APP_ID": "cli_xxx",
|
||||
@@ -82,24 +77,6 @@ class TestConfigEnvOverrides(unittest.TestCase):
|
||||
self.assertIn(Platform.FEISHU, config.get_connected_platforms())
|
||||
|
||||
|
||||
class TestGatewayIntegration(unittest.TestCase):
|
||||
def test_feishu_in_adapter_factory(self):
|
||||
source = Path("gateway/run.py").read_text(encoding="utf-8")
|
||||
self.assertIn("Platform.FEISHU", source)
|
||||
self.assertIn("FeishuAdapter", source)
|
||||
|
||||
def test_feishu_in_authorization_maps(self):
|
||||
source = Path("gateway/run.py").read_text(encoding="utf-8")
|
||||
self.assertIn("FEISHU_ALLOWED_USERS", source)
|
||||
self.assertIn("FEISHU_ALLOW_ALL_USERS", source)
|
||||
|
||||
def test_feishu_toolset_exists(self):
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
self.assertIn("hermes-feishu", TOOLSETS)
|
||||
self.assertIn("hermes-feishu", TOOLSETS["hermes-gateway"]["includes"])
|
||||
|
||||
|
||||
class TestFeishuMessageNormalization(unittest.TestCase):
|
||||
def test_normalize_merge_forward_preserves_summary_lines(self):
|
||||
from gateway.platforms.feishu import normalize_feishu_message
|
||||
@@ -472,27 +449,6 @@ class TestFeishuAdapterMessaging(unittest.TestCase):
|
||||
self.assertEqual(info["type"], "group")
|
||||
|
||||
class TestAdapterModule(unittest.TestCase):
|
||||
def test_adapter_requirement_helper_exists(self):
|
||||
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
|
||||
self.assertIn("def check_feishu_requirements()", source)
|
||||
self.assertIn("FEISHU_AVAILABLE", source)
|
||||
|
||||
def test_adapter_declares_websocket_scope(self):
|
||||
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
|
||||
self.assertIn("Supported modes: websocket, webhook", source)
|
||||
self.assertIn("FEISHU_CONNECTION_MODE", source)
|
||||
|
||||
def test_adapter_registers_message_read_noop_handler(self):
|
||||
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
|
||||
self.assertIn("register_p2_im_message_message_read_v1", source)
|
||||
self.assertIn("def _on_message_read_event", source)
|
||||
|
||||
def test_adapter_registers_reaction_and_card_handlers_for_websocket(self):
|
||||
source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
|
||||
self.assertIn("register_p2_im_message_reaction_created_v1", source)
|
||||
self.assertIn("register_p2_im_message_reaction_deleted_v1", source)
|
||||
self.assertIn("register_p2_card_action_trigger", source)
|
||||
|
||||
def test_load_settings_uses_sdk_defaults_for_invalid_ws_reconnect_values(self):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
@@ -639,6 +595,18 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
calls.append("bot_deleted")
|
||||
return self
|
||||
|
||||
def register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self, _handler):
|
||||
calls.append("p2p_chat_entered")
|
||||
return self
|
||||
|
||||
def register_p2_im_message_recalled_v1(self, _handler):
|
||||
calls.append("message_recalled")
|
||||
return self
|
||||
|
||||
def register_p2_customized_event(self, event_key, _handler):
|
||||
calls.append(f"customized:{event_key}")
|
||||
return self
|
||||
|
||||
def build(self):
|
||||
calls.append("build")
|
||||
return "handler"
|
||||
@@ -664,88 +632,62 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
"card_action",
|
||||
"bot_added",
|
||||
"bot_deleted",
|
||||
"p2p_chat_entered",
|
||||
"message_recalled",
|
||||
"customized:drive.notice.comment_add_v1",
|
||||
"build",
|
||||
],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
def test_add_ack_reaction_uses_ok_emoji(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
captured = {}
|
||||
|
||||
class _ReactionAPI:
|
||||
def create(self, request):
|
||||
captured["request"] = request
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id="r_typing"),
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
|
||||
|
||||
self.assertEqual(reaction_id, "r_typing")
|
||||
self.assertEqual(captured["request"].request_body.reaction_type["emoji_type"], "OK")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_add_ack_reaction_logs_warning_on_failure(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
|
||||
class _ReactionAPI:
|
||||
def create(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct),
|
||||
self.assertLogs("gateway.platforms.feishu", level="WARNING") as logs,
|
||||
):
|
||||
reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
|
||||
|
||||
self.assertIsNone(reaction_id)
|
||||
self.assertTrue(
|
||||
any("Failed to add ack reaction to om_msg" in entry for entry in logs.output),
|
||||
logs.output,
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_ack_reaction_events_are_ignored_to_avoid_feedback_loops(self):
|
||||
def test_bot_origin_reactions_are_dropped_to_avoid_feedback_loops(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = object()
|
||||
|
||||
for emoji in ("Typing", "CrossMark"):
|
||||
event = SimpleNamespace(
|
||||
message_id="om_msg",
|
||||
operator_type="bot",
|
||||
reaction_type=SimpleNamespace(emoji_type=emoji),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe"
|
||||
) as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_not_called()
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_user_reaction_with_managed_emoji_is_still_routed(self):
|
||||
# Operator-origin filter is enough to prevent feedback loops; we must
|
||||
# not additionally swallow user-origin reactions just because their
|
||||
# emoji happens to collide with a lifecycle emoji.
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = SimpleNamespace(is_closed=lambda: False)
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_msg",
|
||||
operator_type="user",
|
||||
reaction_type=SimpleNamespace(emoji_type="OK"),
|
||||
reaction_type=SimpleNamespace(emoji_type="Typing"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe") as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
def _close_coro_and_return_future(coro, _loop):
|
||||
coro.close()
|
||||
return SimpleNamespace(add_done_callback=lambda _: None)
|
||||
|
||||
run_threadsafe.assert_not_called()
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_close_coro_and_return_future,
|
||||
) as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_called_once()
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_requires_mentions_even_when_policy_open(self):
|
||||
@@ -774,6 +716,57 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_other_bot_sender_is_not_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_other_bot", user_id="u_other_bot"),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(event))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_self_bot_sender_is_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
by_open_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id="u_other"),
|
||||
)
|
||||
)
|
||||
by_user_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="app",
|
||||
sender_id=SimpleNamespace(open_id="ou_other", user_id="u_hermes"),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_open_id))
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_user_id))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
@@ -2401,6 +2394,134 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
elements = payload["zh_cn"]["content"][0]
|
||||
self.assertEqual(elements, [{"tag": "md", "text": "可以用 **粗体** 和 *斜体*。"}])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_send_splits_fenced_code_blocks_into_separate_post_rows(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
captured = {}
|
||||
|
||||
class _MessageAPI:
|
||||
def create(self, request):
|
||||
captured["request"] = request
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(message_id="om_codeblock"),
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
message=_MessageAPI(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
content = (
|
||||
"确认已入库 ✓\n"
|
||||
"文件路径:`/root/.hermes/profiles/agent_cto/cron/jobs.json`\n"
|
||||
"**解码后的内容:**\n"
|
||||
"```json\n"
|
||||
'{"cron": "list"}\n'
|
||||
"```\n"
|
||||
"后续说明仍应保留。"
|
||||
)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(
|
||||
adapter.send(
|
||||
chat_id="oc_chat",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(captured["request"].request_body.msg_type, "post")
|
||||
payload = json.loads(captured["request"].request_body.content)
|
||||
rows = payload["zh_cn"]["content"]
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
[
|
||||
{
|
||||
"tag": "md",
|
||||
"text": "确认已入库 ✓\n文件路径:`/root/.hermes/profiles/agent_cto/cron/jobs.json`\n**解码后的内容:**",
|
||||
}
|
||||
],
|
||||
[{"tag": "md", "text": "```json\n{\"cron\": \"list\"}\n```"}],
|
||||
[{"tag": "md", "text": "后续说明仍应保留。"}],
|
||||
],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_post_payload_keeps_fence_like_code_lines_inside_code_block(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
payload = json.loads(
|
||||
adapter._build_post_payload(
|
||||
"before\n```python\n```oops\n```\nafter"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
payload["zh_cn"]["content"],
|
||||
[
|
||||
[{"tag": "md", "text": "before"}],
|
||||
[{"tag": "md", "text": "```python\n```oops\n```"}],
|
||||
[{"tag": "md", "text": "after"}],
|
||||
],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_post_payload_preserves_trailing_spaces_in_code_block(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
payload = json.loads(
|
||||
adapter._build_post_payload(
|
||||
"before\n```python\nline with two spaces \n```\nafter"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
payload["zh_cn"]["content"],
|
||||
[
|
||||
[{"tag": "md", "text": "before"}],
|
||||
[{"tag": "md", "text": "```python\nline with two spaces \n```"}],
|
||||
[{"tag": "md", "text": "after"}],
|
||||
],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_post_payload_splits_multiple_fenced_code_blocks(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
payload = json.loads(
|
||||
adapter._build_post_payload(
|
||||
"before\n```python\nprint(1)\n```\nmiddle\n```json\n{}\n```\nafter"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
payload["zh_cn"]["content"],
|
||||
[
|
||||
[{"tag": "md", "text": "before"}],
|
||||
[{"tag": "md", "text": "```python\nprint(1)\n```"}],
|
||||
[{"tag": "md", "text": "middle"}],
|
||||
[{"tag": "md", "text": "```json\n{}\n```"}],
|
||||
[{"tag": "md", "text": "after"}],
|
||||
],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_send_falls_back_to_text_when_post_payload_is_rejected(self):
|
||||
from gateway.config import PlatformConfig
|
||||
@@ -2536,6 +2657,281 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestHydrateBotIdentity(unittest.TestCase):
|
||||
"""Hydration of bot identity via /open-apis/bot/v3/info and application info.
|
||||
|
||||
Covers the manual-setup path where FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID
|
||||
are not configured. Hydration must populate _bot_open_id so that
|
||||
_is_self_sent_bot_message() can filter the adapter's own outbound echoes.
|
||||
"""
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
return FeishuAdapter(PlatformConfig())
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydration_populates_open_id_from_bot_info(self):
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
{
|
||||
"code": 0,
|
||||
"bot": {
|
||||
"bot_name": "Hermes Bot",
|
||||
"open_id": "ou_hermes_hydrated",
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
response = SimpleNamespace(content=payload)
|
||||
adapter._client.request = Mock(return_value=response)
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_hermes_hydrated")
|
||||
self.assertEqual(adapter._bot_name, "Hermes Bot")
|
||||
# Application-info fallback must NOT run when bot_name is already set.
|
||||
self.assertFalse(
|
||||
adapter._client.application.v6.application.get.called
|
||||
if hasattr(adapter._client, "application") else False
|
||||
)
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_env",
|
||||
"FEISHU_BOT_NAME": "Env Hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_hydration_skipped_when_env_vars_supply_both_fields(self):
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
adapter._client.request = Mock()
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
# Neither probe should run — both fields are already populated.
|
||||
adapter._client.request.assert_not_called()
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env")
|
||||
self.assertEqual(adapter._bot_name, "Env Hermes")
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_BOT_OPEN_ID": "ou_env"}, clear=True)
|
||||
def test_hydration_fills_only_missing_fields(self):
|
||||
"""Env-var open_id must NOT be overwritten by a different probe value."""
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
{
|
||||
"code": 0,
|
||||
"bot": {
|
||||
"bot_name": "Hermes Bot",
|
||||
"open_id": "ou_probe_DIFFERENT",
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
adapter._client.request = Mock(return_value=SimpleNamespace(content=payload))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env") # preserved
|
||||
self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self):
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
adapter._client.request = Mock(side_effect=RuntimeError("network down"))
|
||||
|
||||
# Make the application-info fallback succeed for _bot_name.
|
||||
app_response = Mock()
|
||||
app_response.success = Mock(return_value=True)
|
||||
app_response.data = SimpleNamespace(app=SimpleNamespace(app_name="Fallback Bot"))
|
||||
adapter._client.application.v6.application.get = Mock(return_value=app_response)
|
||||
adapter._build_get_application_request = Mock(return_value=object())
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
# Primary probe failed — open_id stays empty, but bot_name came from app-info.
|
||||
self.assertEqual(adapter._bot_open_id, "")
|
||||
self.assertEqual(adapter._bot_name, "Fallback Bot")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydrated_open_id_enables_self_send_filter(self):
|
||||
"""E2E: after hydration, _is_self_sent_bot_message() rejects adapter's own id."""
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
{"code": 0, "bot": {"bot_name": "Hermes", "open_id": "ou_hermes"}}
|
||||
).encode("utf-8")
|
||||
adapter._client.request = Mock(return_value=SimpleNamespace(content=payload))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id=""),
|
||||
)
|
||||
)
|
||||
peer_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_peer_bot", user_id=""),
|
||||
)
|
||||
)
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(self_event))
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(peer_event))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestPendingInboundQueue(unittest.TestCase):
|
||||
"""Tests for the loop-not-ready race (#5499): inbound events arriving
|
||||
before or during adapter loop transitions must be queued for replay
|
||||
rather than silently dropped."""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_event_queued_when_loop_not_ready(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = None # Simulate "before start()" or "during reconnect"
|
||||
|
||||
with patch("gateway.platforms.feishu.threading.Thread") as thread_cls:
|
||||
adapter._on_message_event(SimpleNamespace(tag="evt-1"))
|
||||
adapter._on_message_event(SimpleNamespace(tag="evt-2"))
|
||||
adapter._on_message_event(SimpleNamespace(tag="evt-3"))
|
||||
|
||||
# All three queued, none dropped.
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 3)
|
||||
# Only ONE drainer thread scheduled, not one per event.
|
||||
self.assertEqual(thread_cls.call_count, 1)
|
||||
# Drain scheduled flag set.
|
||||
self.assertTrue(adapter._pending_drain_scheduled)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_drainer_replays_queued_events_when_loop_becomes_ready(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = None
|
||||
adapter._running = True
|
||||
|
||||
class _ReadyLoop:
|
||||
def is_closed(self):
|
||||
return False
|
||||
|
||||
# Queue three events while loop is None (simulate the race).
|
||||
events = [SimpleNamespace(tag=f"evt-{i}") for i in range(3)]
|
||||
with patch("gateway.platforms.feishu.threading.Thread"):
|
||||
for ev in events:
|
||||
adapter._on_message_event(ev)
|
||||
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 3)
|
||||
|
||||
# Now the loop becomes ready; run the drainer inline (not as a thread)
|
||||
# to verify it replays the queue.
|
||||
adapter._loop = _ReadyLoop()
|
||||
|
||||
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
|
||||
submitted: list = []
|
||||
|
||||
def _submit(coro, _loop):
|
||||
submitted.append(coro)
|
||||
coro.close()
|
||||
return future
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_submit,
|
||||
) as submit:
|
||||
adapter._drain_pending_inbound_events()
|
||||
|
||||
# All three events dispatched to the loop.
|
||||
self.assertEqual(submit.call_count, 3)
|
||||
# Queue emptied.
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 0)
|
||||
# Drain flag reset so a future race can schedule a new drainer.
|
||||
self.assertFalse(adapter._pending_drain_scheduled)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_drainer_drops_queue_when_adapter_shuts_down(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = None
|
||||
adapter._running = False # Shutdown state
|
||||
|
||||
with patch("gateway.platforms.feishu.threading.Thread"):
|
||||
adapter._on_message_event(SimpleNamespace(tag="evt-lost"))
|
||||
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 1)
|
||||
|
||||
# Drainer should drop the queue immediately since _running is False.
|
||||
adapter._drain_pending_inbound_events()
|
||||
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 0)
|
||||
self.assertFalse(adapter._pending_drain_scheduled)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_queue_cap_evicts_oldest_beyond_max_depth(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = None
|
||||
adapter._pending_inbound_max_depth = 3 # Shrink for test
|
||||
|
||||
with patch("gateway.platforms.feishu.threading.Thread"):
|
||||
for i in range(5):
|
||||
adapter._on_message_event(SimpleNamespace(tag=f"evt-{i}"))
|
||||
|
||||
# Only the last 3 should remain; evt-0 and evt-1 dropped.
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 3)
|
||||
tags = [getattr(e, "tag", None) for e in adapter._pending_inbound_events]
|
||||
self.assertEqual(tags, ["evt-2", "evt-3", "evt-4"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_normal_path_unchanged_when_loop_ready(self):
|
||||
"""When the loop is ready, events should dispatch directly without
|
||||
ever touching the pending queue."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
|
||||
class _ReadyLoop:
|
||||
def is_closed(self):
|
||||
return False
|
||||
|
||||
adapter._loop = _ReadyLoop()
|
||||
|
||||
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
|
||||
|
||||
def _submit(coro, _loop):
|
||||
coro.close()
|
||||
return future
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_submit,
|
||||
) as submit, patch(
|
||||
"gateway.platforms.feishu.threading.Thread"
|
||||
) as thread_cls:
|
||||
adapter._on_message_event(SimpleNamespace(tag="evt"))
|
||||
|
||||
self.assertEqual(submit.call_count, 1)
|
||||
self.assertEqual(len(adapter._pending_inbound_events), 0)
|
||||
self.assertFalse(adapter._pending_drain_scheduled)
|
||||
# No drainer thread spawned when the happy path runs.
|
||||
self.assertEqual(thread_cls.call_count, 0)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestWebhookSecurity(unittest.TestCase):
|
||||
"""Tests for webhook signature verification, rate limiting, and body size limits."""
|
||||
@@ -2855,3 +3251,231 @@ class TestSenderNameResolution(unittest.TestCase):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken"))
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestProcessingReactions(unittest.TestCase):
|
||||
"""Typing on start → removed on SUCCESS, swapped for CrossMark on FAILURE,
|
||||
removed (no replacement) on CANCELLED."""
|
||||
|
||||
@staticmethod
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def _build_adapter(
|
||||
self,
|
||||
create_success: bool = True,
|
||||
delete_success: bool = True,
|
||||
next_reaction_id: str = "r1",
|
||||
):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
tracker = SimpleNamespace(
|
||||
create_calls=[],
|
||||
delete_calls=[],
|
||||
next_reaction_id=next_reaction_id,
|
||||
create_success=create_success,
|
||||
delete_success=delete_success,
|
||||
)
|
||||
|
||||
def _create(request):
|
||||
tracker.create_calls.append(
|
||||
request.request_body.reaction_type["emoji_type"]
|
||||
)
|
||||
if tracker.create_success:
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id=tracker.next_reaction_id),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
success=lambda: False, code=99, msg="rejected", data=None,
|
||||
)
|
||||
|
||||
def _delete(request):
|
||||
tracker.delete_calls.append(request.reaction_id)
|
||||
return SimpleNamespace(
|
||||
success=lambda: tracker.delete_success,
|
||||
code=0 if tracker.delete_success else 99,
|
||||
msg="success" if tracker.delete_success else "rejected",
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
message_reaction=SimpleNamespace(create=_create, delete=_delete),
|
||||
),
|
||||
),
|
||||
)
|
||||
return adapter, tracker
|
||||
|
||||
@staticmethod
|
||||
def _event(message_id: str = "om_msg"):
|
||||
return SimpleNamespace(message_id=message_id)
|
||||
|
||||
def _patch_to_thread(self):
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct)
|
||||
|
||||
# ------------------------------------------------------------------ start
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_adds_typing_and_caches_reaction_id(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(adapter._pending_processing_reactions["om_msg"], "r_typing")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_is_idempotent_for_same_message_id(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_does_not_cache_when_create_fails(self):
|
||||
adapter, tracker = self._build_adapter(create_success=False)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
# --------------------------------------------------------------- complete
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_success_removes_typing_and_adds_nothing(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_failure_removes_typing_then_adds_cross_mark(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing", "CrossMark"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_cancelled_removes_typing_and_adds_nothing(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.CANCELLED)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_failure_without_preceding_start_still_adds_cross_mark(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["CrossMark"])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_success_without_preceding_start_is_full_noop(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, [])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
# ------------------------- delete failure: don't stack badges -----------
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_delete_failure_on_failure_outcome_skips_cross_mark(self):
|
||||
# Removing Typing is best-effort — but if it fails, we must NOT
|
||||
# additionally add CrossMark, or the UI would show two contradictory
|
||||
# badges. The handle stays in the cache for LRU to clean up later.
|
||||
adapter, tracker = self._build_adapter(
|
||||
next_reaction_id="r_typing", delete_success=False,
|
||||
)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"]) # CrossMark NOT added
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"]) # delete was attempted
|
||||
self.assertEqual(
|
||||
adapter._pending_processing_reactions["om_msg"], "r_typing",
|
||||
) # handle retained
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_delete_failure_on_success_outcome_retains_handle(self):
|
||||
adapter, tracker = self._build_adapter(
|
||||
next_reaction_id="r_typing", delete_success=False,
|
||||
)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertEqual(
|
||||
adapter._pending_processing_reactions["om_msg"], "r_typing",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- env toggle
|
||||
@patch.dict(os.environ, {"FEISHU_REACTIONS": "false"}, clear=True)
|
||||
def test_env_disable_short_circuits_both_hooks(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, [])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
# ------------------------------------------------------------- LRU bounds
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_cache_evicts_oldest_entry_beyond_size_limit(self):
|
||||
from gateway.platforms.feishu import _FEISHU_PROCESSING_REACTION_CACHE_SIZE
|
||||
|
||||
adapter, _ = self._build_adapter()
|
||||
counter = {"n": 0}
|
||||
|
||||
def _create(_request):
|
||||
counter["n"] += 1
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id=f"r{counter['n']}"),
|
||||
)
|
||||
|
||||
adapter._client.im.v1.message_reaction.create = _create
|
||||
|
||||
with self._patch_to_thread():
|
||||
for i in range(_FEISHU_PROCESSING_REACTION_CACHE_SIZE + 1):
|
||||
self._run(adapter.on_processing_start(self._event(f"om_{i}")))
|
||||
|
||||
self.assertNotIn("om_0", adapter._pending_processing_reactions)
|
||||
self.assertIn(
|
||||
f"om_{_FEISHU_PROCESSING_REACTION_CACHE_SIZE}",
|
||||
adapter._pending_processing_reactions,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(adapter._pending_processing_reactions),
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE,
|
||||
)
|
||||
|
||||
261
tests/gateway/test_feishu_comment.py
Normal file
261
tests/gateway/test_feishu_comment.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Tests for feishu_comment — event filtering, access control integration, wiki reverse lookup."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.feishu_comment import (
|
||||
parse_drive_comment_event,
|
||||
_ALLOWED_NOTICE_TYPES,
|
||||
_sanitize_comment_text,
|
||||
)
|
||||
|
||||
|
||||
def _make_event(
|
||||
comment_id="c1",
|
||||
reply_id="r1",
|
||||
notice_type="add_reply",
|
||||
file_token="docx_token",
|
||||
file_type="docx",
|
||||
from_open_id="ou_user",
|
||||
to_open_id="ou_bot",
|
||||
is_mentioned=True,
|
||||
):
|
||||
"""Build a minimal drive comment event SimpleNamespace."""
|
||||
return SimpleNamespace(event={
|
||||
"event_id": "evt_1",
|
||||
"comment_id": comment_id,
|
||||
"reply_id": reply_id,
|
||||
"is_mentioned": is_mentioned,
|
||||
"timestamp": "1713200000",
|
||||
"notice_meta": {
|
||||
"file_token": file_token,
|
||||
"file_type": file_type,
|
||||
"notice_type": notice_type,
|
||||
"from_user_id": {"open_id": from_open_id},
|
||||
"to_user_id": {"open_id": to_open_id},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class TestParseEvent(unittest.TestCase):
|
||||
def test_parse_valid_event(self):
|
||||
evt = _make_event()
|
||||
parsed = parse_drive_comment_event(evt)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(parsed["comment_id"], "c1")
|
||||
self.assertEqual(parsed["file_type"], "docx")
|
||||
self.assertEqual(parsed["from_open_id"], "ou_user")
|
||||
self.assertEqual(parsed["to_open_id"], "ou_bot")
|
||||
|
||||
def test_parse_missing_event_attr(self):
|
||||
self.assertIsNone(parse_drive_comment_event(object()))
|
||||
|
||||
def test_parse_none_event(self):
|
||||
self.assertIsNone(parse_drive_comment_event(SimpleNamespace()))
|
||||
|
||||
|
||||
class TestEventFiltering(unittest.TestCase):
|
||||
"""Test the filtering logic in handle_drive_comment_event."""
|
||||
|
||||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events where from_open_id == self_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(from_open_id="ou_bot", to_open_id="ou_bot")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events where to_open_id != self_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(to_open_id="ou_other_bot")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events with empty to_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(to_open_id="")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events with unsupported notice_type should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(notice_type="resolve_comment")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
def test_allowed_notice_types(self):
|
||||
self.assertIn("add_comment", _ALLOWED_NOTICE_TYPES)
|
||||
self.assertIn("add_reply", _ALLOWED_NOTICE_TYPES)
|
||||
self.assertNotIn("resolve_comment", _ALLOWED_NOTICE_TYPES)
|
||||
|
||||
|
||||
class TestAccessControlIntegration(unittest.TestCase):
|
||||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
|
||||
"""Denied user should not trigger typing reaction or agent."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
mock_resolve.return_value = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
|
||||
mock_load.return_value = Mock()
|
||||
|
||||
client = Mock()
|
||||
evt = _make_event()
|
||||
self._run(handle_drive_comment_event(client, evt, self_open_id="ou_bot"))
|
||||
|
||||
# No API calls should be made for denied users
|
||||
client.request.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
|
||||
"""Disabled comments should return immediately."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
mock_resolve.return_value = ResolvedCommentRule(False, "allowlist", frozenset(), "top")
|
||||
mock_load.return_value = Mock()
|
||||
|
||||
evt = _make_event()
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_allowed.assert_not_called()
|
||||
|
||||
|
||||
class TestSanitizeCommentText(unittest.TestCase):
|
||||
def test_angle_brackets_escaped(self):
|
||||
self.assertEqual(_sanitize_comment_text("List<String>"), "List<String>")
|
||||
|
||||
def test_ampersand_escaped_first(self):
|
||||
self.assertEqual(_sanitize_comment_text("a & b"), "a & b")
|
||||
|
||||
def test_ampersand_not_double_escaped(self):
|
||||
result = _sanitize_comment_text("a < b & c > d")
|
||||
self.assertEqual(result, "a < b & c > d")
|
||||
self.assertNotIn("&lt;", result)
|
||||
self.assertNotIn("&gt;", result)
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
self.assertEqual(_sanitize_comment_text("hello world"), "hello world")
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(_sanitize_comment_text(""), "")
|
||||
|
||||
def test_code_snippet(self):
|
||||
text = 'if (a < b && c > 0) { return "ok"; }'
|
||||
result = _sanitize_comment_text(text)
|
||||
self.assertNotIn("<", result)
|
||||
self.assertNotIn(">", result)
|
||||
self.assertIn("<", result)
|
||||
self.assertIn(">", result)
|
||||
|
||||
|
||||
class TestWikiReverseLookup(unittest.TestCase):
|
||||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_success(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (0, "Success", {
|
||||
"node": {"node_token": "WIKI_TOKEN_123", "obj_token": "docx_abc"},
|
||||
})
|
||||
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
|
||||
self.assertEqual(result, "WIKI_TOKEN_123")
|
||||
# Verify correct API params
|
||||
call_args = mock_exec.call_args
|
||||
queries = call_args[1].get("queries") or call_args[0][3]
|
||||
query_dict = dict(queries)
|
||||
self.assertEqual(query_dict["token"], "docx_abc")
|
||||
self.assertEqual(query_dict["obj_type"], "docx")
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_not_wiki(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (131001, "not found", {})
|
||||
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_service_error(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (500, "internal error", {})
|
||||
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=True)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=True)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment.batch_query_comment", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment.query_document_meta", new_callable=AsyncMock)
|
||||
def test_wiki_lookup_triggered_when_no_exact_match(
|
||||
self, mock_meta, mock_batch, mock_reaction,
|
||||
mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup,
|
||||
):
|
||||
"""Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
# First resolve returns wildcard (no exact match), second returns exact wiki match
|
||||
mock_resolve.side_effect = [
|
||||
ResolvedCommentRule(True, "allowlist", frozenset(), "wildcard"),
|
||||
ResolvedCommentRule(True, "allowlist", frozenset(), "exact:wiki:WIKI123"),
|
||||
]
|
||||
mock_load.return_value = Mock()
|
||||
mock_lookup.return_value = "WIKI123"
|
||||
mock_meta.return_value = {"title": "Test", "url": ""}
|
||||
mock_batch.return_value = {"is_whole": False, "quote": ""}
|
||||
|
||||
evt = _make_event()
|
||||
# Will proceed past access control but fail later — that's OK, we just test the lookup
|
||||
try:
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mock_lookup.assert_called_once_with(unittest.mock.ANY, "docx", "docx_token")
|
||||
self.assertEqual(mock_resolve.call_count, 2)
|
||||
# Second call should include wiki_token
|
||||
second_call_kwargs = mock_resolve.call_args_list[1]
|
||||
self.assertEqual(second_call_kwargs[1].get("wiki_token") or second_call_kwargs[0][3], "WIKI123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
320
tests/gateway/test_feishu_comment_rules.py
Normal file
320
tests/gateway/test_feishu_comment_rules.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Tests for feishu_comment_rules — 3-tier access control rule engine."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway.platforms.feishu_comment_rules import (
|
||||
CommentsConfig,
|
||||
CommentDocumentRule,
|
||||
ResolvedCommentRule,
|
||||
_MtimeCache,
|
||||
_parse_document_rule,
|
||||
has_wiki_keys,
|
||||
is_user_allowed,
|
||||
load_config,
|
||||
pairing_add,
|
||||
pairing_list,
|
||||
pairing_remove,
|
||||
resolve_rule,
|
||||
)
|
||||
|
||||
|
||||
class TestCommentDocumentRuleParsing(unittest.TestCase):
|
||||
def test_parse_full_rule(self):
|
||||
rule = _parse_document_rule({
|
||||
"enabled": False,
|
||||
"policy": "allowlist",
|
||||
"allow_from": ["ou_a", "ou_b"],
|
||||
})
|
||||
self.assertFalse(rule.enabled)
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertEqual(rule.allow_from, frozenset(["ou_a", "ou_b"]))
|
||||
|
||||
def test_parse_partial_rule(self):
|
||||
rule = _parse_document_rule({"policy": "allowlist"})
|
||||
self.assertIsNone(rule.enabled)
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertIsNone(rule.allow_from)
|
||||
|
||||
def test_parse_empty_rule(self):
|
||||
rule = _parse_document_rule({})
|
||||
self.assertIsNone(rule.enabled)
|
||||
self.assertIsNone(rule.policy)
|
||||
self.assertIsNone(rule.allow_from)
|
||||
|
||||
def test_invalid_policy_ignored(self):
|
||||
rule = _parse_document_rule({"policy": "invalid_value"})
|
||||
self.assertIsNone(rule.policy)
|
||||
|
||||
|
||||
class TestResolveRule(unittest.TestCase):
|
||||
def test_exact_match(self):
|
||||
cfg = CommentsConfig(
|
||||
policy="pairing",
|
||||
allow_from=frozenset(["ou_top"]),
|
||||
documents={
|
||||
"docx:abc": CommentDocumentRule(policy="allowlist"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "abc")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertTrue(rule.match_source.startswith("exact:"))
|
||||
|
||||
def test_wildcard_match(self):
|
||||
cfg = CommentsConfig(
|
||||
policy="pairing",
|
||||
documents={
|
||||
"*": CommentDocumentRule(policy="allowlist"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "unknown")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertEqual(rule.match_source, "wildcard")
|
||||
|
||||
def test_top_level_fallback(self):
|
||||
cfg = CommentsConfig(policy="pairing", allow_from=frozenset(["ou_top"]))
|
||||
rule = resolve_rule(cfg, "docx", "whatever")
|
||||
self.assertEqual(rule.policy, "pairing")
|
||||
self.assertEqual(rule.allow_from, frozenset(["ou_top"]))
|
||||
self.assertEqual(rule.match_source, "top")
|
||||
|
||||
def test_exact_overrides_wildcard(self):
|
||||
cfg = CommentsConfig(
|
||||
policy="pairing",
|
||||
documents={
|
||||
"*": CommentDocumentRule(policy="pairing"),
|
||||
"docx:abc": CommentDocumentRule(policy="allowlist"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "abc")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertTrue(rule.match_source.startswith("exact:"))
|
||||
|
||||
def test_field_by_field_fallback(self):
|
||||
"""Exact sets policy, wildcard sets allow_from, enabled from top."""
|
||||
cfg = CommentsConfig(
|
||||
enabled=True,
|
||||
policy="pairing",
|
||||
allow_from=frozenset(["ou_top"]),
|
||||
documents={
|
||||
"*": CommentDocumentRule(allow_from=frozenset(["ou_wildcard"])),
|
||||
"docx:abc": CommentDocumentRule(policy="allowlist"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "abc")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertEqual(rule.allow_from, frozenset(["ou_wildcard"]))
|
||||
self.assertTrue(rule.enabled)
|
||||
|
||||
def test_explicit_empty_allow_from_does_not_fall_through(self):
|
||||
"""allow_from=[] on exact should NOT inherit from wildcard or top."""
|
||||
cfg = CommentsConfig(
|
||||
allow_from=frozenset(["ou_top"]),
|
||||
documents={
|
||||
"*": CommentDocumentRule(allow_from=frozenset(["ou_wildcard"])),
|
||||
"docx:abc": CommentDocumentRule(
|
||||
policy="allowlist",
|
||||
allow_from=frozenset(),
|
||||
),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "abc")
|
||||
self.assertEqual(rule.allow_from, frozenset())
|
||||
|
||||
def test_wiki_token_match(self):
|
||||
cfg = CommentsConfig(
|
||||
policy="pairing",
|
||||
documents={
|
||||
"wiki:WIKI123": CommentDocumentRule(policy="allowlist"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "obj_token", wiki_token="WIKI123")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertTrue(rule.match_source.startswith("exact:wiki:"))
|
||||
|
||||
def test_exact_takes_priority_over_wiki(self):
|
||||
cfg = CommentsConfig(
|
||||
documents={
|
||||
"docx:abc": CommentDocumentRule(policy="allowlist"),
|
||||
"wiki:WIKI123": CommentDocumentRule(policy="pairing"),
|
||||
},
|
||||
)
|
||||
rule = resolve_rule(cfg, "docx", "abc", wiki_token="WIKI123")
|
||||
self.assertEqual(rule.policy, "allowlist")
|
||||
self.assertTrue(rule.match_source.startswith("exact:docx:"))
|
||||
|
||||
def test_default_config(self):
|
||||
cfg = CommentsConfig()
|
||||
rule = resolve_rule(cfg, "docx", "anything")
|
||||
self.assertTrue(rule.enabled)
|
||||
self.assertEqual(rule.policy, "pairing")
|
||||
self.assertEqual(rule.allow_from, frozenset())
|
||||
|
||||
|
||||
class TestHasWikiKeys(unittest.TestCase):
|
||||
def test_no_wiki_keys(self):
|
||||
cfg = CommentsConfig(documents={
|
||||
"docx:abc": CommentDocumentRule(policy="allowlist"),
|
||||
"*": CommentDocumentRule(policy="pairing"),
|
||||
})
|
||||
self.assertFalse(has_wiki_keys(cfg))
|
||||
|
||||
def test_has_wiki_keys(self):
|
||||
cfg = CommentsConfig(documents={
|
||||
"wiki:WIKI123": CommentDocumentRule(policy="allowlist"),
|
||||
})
|
||||
self.assertTrue(has_wiki_keys(cfg))
|
||||
|
||||
def test_empty_documents(self):
|
||||
cfg = CommentsConfig()
|
||||
self.assertFalse(has_wiki_keys(cfg))
|
||||
|
||||
|
||||
class TestIsUserAllowed(unittest.TestCase):
|
||||
def test_allowlist_allows_listed(self):
|
||||
rule = ResolvedCommentRule(True, "allowlist", frozenset(["ou_a"]), "top")
|
||||
self.assertTrue(is_user_allowed(rule, "ou_a"))
|
||||
|
||||
def test_allowlist_denies_unlisted(self):
|
||||
rule = ResolvedCommentRule(True, "allowlist", frozenset(["ou_a"]), "top")
|
||||
self.assertFalse(is_user_allowed(rule, "ou_b"))
|
||||
|
||||
def test_allowlist_empty_denies_all(self):
|
||||
rule = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
|
||||
self.assertFalse(is_user_allowed(rule, "ou_anyone"))
|
||||
|
||||
def test_pairing_allows_in_allow_from(self):
|
||||
rule = ResolvedCommentRule(True, "pairing", frozenset(["ou_a"]), "top")
|
||||
self.assertTrue(is_user_allowed(rule, "ou_a"))
|
||||
|
||||
def test_pairing_checks_store(self):
|
||||
rule = ResolvedCommentRule(True, "pairing", frozenset(), "top")
|
||||
with patch(
|
||||
"gateway.platforms.feishu_comment_rules._load_pairing_approved",
|
||||
return_value={"ou_approved"},
|
||||
):
|
||||
self.assertTrue(is_user_allowed(rule, "ou_approved"))
|
||||
self.assertFalse(is_user_allowed(rule, "ou_unknown"))
|
||||
|
||||
|
||||
class TestMtimeCache(unittest.TestCase):
|
||||
def test_returns_empty_dict_for_missing_file(self):
|
||||
cache = _MtimeCache(Path("/nonexistent/path.json"))
|
||||
self.assertEqual(cache.load(), {})
|
||||
|
||||
def test_reads_file_and_caches(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({"key": "value"}, f)
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
try:
|
||||
cache = _MtimeCache(path)
|
||||
data = cache.load()
|
||||
self.assertEqual(data, {"key": "value"})
|
||||
# Second load should use cache (same mtime)
|
||||
data2 = cache.load()
|
||||
self.assertEqual(data2, {"key": "value"})
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_reloads_on_mtime_change(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({"v": 1}, f)
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
try:
|
||||
cache = _MtimeCache(path)
|
||||
self.assertEqual(cache.load(), {"v": 1})
|
||||
# Modify file
|
||||
time.sleep(0.05)
|
||||
with open(path, "w") as f2:
|
||||
json.dump({"v": 2}, f2)
|
||||
# Force mtime change detection
|
||||
os.utime(path, (time.time() + 1, time.time() + 1))
|
||||
self.assertEqual(cache.load(), {"v": 2})
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
|
||||
class TestLoadConfig(unittest.TestCase):
|
||||
def test_load_with_documents(self):
|
||||
raw = {
|
||||
"enabled": True,
|
||||
"policy": "allowlist",
|
||||
"allow_from": ["ou_a"],
|
||||
"documents": {
|
||||
"*": {"policy": "pairing"},
|
||||
"docx:abc": {"policy": "allowlist", "allow_from": ["ou_b"]},
|
||||
},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(raw, f)
|
||||
path = Path(f.name)
|
||||
try:
|
||||
with patch("gateway.platforms.feishu_comment_rules.RULES_FILE", path):
|
||||
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(path)):
|
||||
cfg = load_config()
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertEqual(cfg.policy, "allowlist")
|
||||
self.assertEqual(cfg.allow_from, frozenset(["ou_a"]))
|
||||
self.assertIn("*", cfg.documents)
|
||||
self.assertIn("docx:abc", cfg.documents)
|
||||
self.assertEqual(cfg.documents["docx:abc"].policy, "allowlist")
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_load_missing_file_returns_defaults(self):
|
||||
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
|
||||
cfg = load_config()
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertEqual(cfg.policy, "pairing")
|
||||
self.assertEqual(cfg.allow_from, frozenset())
|
||||
self.assertEqual(cfg.documents, {})
|
||||
|
||||
|
||||
class TestPairingStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
self._pairing_file = Path(self._tmpdir) / "pairing.json"
|
||||
with open(self._pairing_file, "w") as f:
|
||||
json.dump({"approved": {}}, f)
|
||||
self._patcher_file = patch("gateway.platforms.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
|
||||
self._patcher_cache = patch(
|
||||
"gateway.platforms.feishu_comment_rules._pairing_cache",
|
||||
_MtimeCache(self._pairing_file),
|
||||
)
|
||||
self._patcher_file.start()
|
||||
self._patcher_cache.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._patcher_cache.stop()
|
||||
self._patcher_file.stop()
|
||||
if self._pairing_file.exists():
|
||||
self._pairing_file.unlink()
|
||||
os.rmdir(self._tmpdir)
|
||||
|
||||
def test_add_and_list(self):
|
||||
self.assertTrue(pairing_add("ou_new"))
|
||||
approved = pairing_list()
|
||||
self.assertIn("ou_new", approved)
|
||||
|
||||
def test_add_duplicate(self):
|
||||
pairing_add("ou_a")
|
||||
self.assertFalse(pairing_add("ou_a"))
|
||||
|
||||
def test_remove(self):
|
||||
pairing_add("ou_a")
|
||||
self.assertTrue(pairing_remove("ou_a"))
|
||||
self.assertNotIn("ou_a", pairing_list())
|
||||
|
||||
def test_remove_nonexistent(self):
|
||||
self.assertFalse(pairing_remove("ou_nobody"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -202,6 +202,22 @@ class TestFlushAgentSilenced:
|
||||
sys.stdout = old_stdout
|
||||
assert buf.getvalue() == "", "no-op print_fn spinner must not write to stdout"
|
||||
|
||||
def test_flush_agent_closes_resources_after_run(self, monkeypatch):
|
||||
"""Memory flush should close temporary agent resources after the turn."""
|
||||
runner, tmp_agent, _ = _make_flush_context(monkeypatch)
|
||||
tmp_agent.shutdown_memory_provider = MagicMock()
|
||||
tmp_agent.close = MagicMock()
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: Path("/nonexistent"))}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_cleanup")
|
||||
|
||||
tmp_agent.shutdown_memory_provider.assert_called_once()
|
||||
tmp_agent.close.assert_called_once()
|
||||
|
||||
|
||||
class TestFlushPromptStructure:
|
||||
"""Verify the flush prompt retains its core instructions."""
|
||||
|
||||
@@ -469,18 +469,6 @@ class TestConfigIntegration:
|
||||
assert ha.extra["watch_domains"] == ["climate"]
|
||||
assert ha.extra["cooldown_seconds"] == 45
|
||||
|
||||
def test_connected_platforms_includes_ha(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.HOMEASSISTANT: PlatformConfig(enabled=True, token="tok"),
|
||||
Platform.TELEGRAM: PlatformConfig(enabled=False, token="t"),
|
||||
},
|
||||
)
|
||||
connected = config.get_connected_platforms()
|
||||
assert Platform.HOMEASSISTANT in connected
|
||||
assert Platform.TELEGRAM not in connected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send() via REST API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -582,27 +570,6 @@ class TestSendViaRestApi:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolsetIntegration:
|
||||
def test_homeassistant_toolset_resolves(self):
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
tools = resolve_toolset("homeassistant")
|
||||
assert set(tools) == {"ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"}
|
||||
|
||||
def test_gateway_toolset_includes_ha_tools(self):
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
gateway_tools = resolve_toolset("hermes-gateway")
|
||||
for tool in ("ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"):
|
||||
assert tool in gateway_tools
|
||||
|
||||
def test_hermes_core_tools_includes_ha(self):
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
|
||||
for tool in ("ha_list_entities", "ha_get_state", "ha_call_service", "ha_list_services"):
|
||||
assert tool in _HERMES_CORE_TOOLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket URL construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
54
tests/gateway/test_insights_unicode_flags.py
Normal file
54
tests/gateway/test_insights_unicode_flags.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Tests for Unicode dash normalization in /insights command flag parsing.
|
||||
|
||||
Telegram on iOS auto-converts -- to em/en dashes. The /insights handler
|
||||
normalizes these before parsing --days and --source flags.
|
||||
"""
|
||||
import re
|
||||
import pytest
|
||||
|
||||
|
||||
# The regex from gateway/run.py insights handler
|
||||
_UNICODE_DASH_RE = re.compile(r'[\u2012\u2013\u2014\u2015](days|source)')
|
||||
|
||||
|
||||
def _normalize_insights_args(raw: str) -> str:
|
||||
"""Apply the same normalization as the /insights handler."""
|
||||
return _UNICODE_DASH_RE.sub(r'--\1', raw)
|
||||
|
||||
|
||||
class TestInsightsUnicodeDashFlags:
|
||||
"""--days and --source must survive iOS Unicode dash conversion."""
|
||||
|
||||
@pytest.mark.parametrize("input_str,expected", [
|
||||
# Standard double hyphen (baseline)
|
||||
("--days 7", "--days 7"),
|
||||
("--source telegram", "--source telegram"),
|
||||
# Em dash (U+2014)
|
||||
("\u2014days 7", "--days 7"),
|
||||
("\u2014source telegram", "--source telegram"),
|
||||
# En dash (U+2013)
|
||||
("\u2013days 7", "--days 7"),
|
||||
("\u2013source telegram", "--source telegram"),
|
||||
# Figure dash (U+2012)
|
||||
("\u2012days 7", "--days 7"),
|
||||
# Horizontal bar (U+2015)
|
||||
("\u2015days 7", "--days 7"),
|
||||
# Combined flags with em dashes
|
||||
("\u2014days 30 \u2014source cli", "--days 30 --source cli"),
|
||||
])
|
||||
def test_unicode_dash_normalized(self, input_str, expected):
|
||||
result = _normalize_insights_args(input_str)
|
||||
assert result == expected
|
||||
|
||||
def test_regular_hyphens_unaffected(self):
|
||||
"""Normal --days/--source must pass through unchanged."""
|
||||
assert _normalize_insights_args("--days 7 --source discord") == "--days 7 --source discord"
|
||||
|
||||
def test_bare_number_still_works(self):
|
||||
"""Shorthand /insights 7 (no flag) must not be mangled."""
|
||||
assert _normalize_insights_args("7") == "7"
|
||||
|
||||
def test_no_flags_unchanged(self):
|
||||
"""Input with no flags passes through as-is."""
|
||||
assert _normalize_insights_args("") == ""
|
||||
assert _normalize_insights_args("30") == "30"
|
||||
@@ -230,6 +230,59 @@ async def test_notify_on_complete_preserves_user_identity(monkeypatch, tmp_path)
|
||||
assert event.source.user_name == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path):
|
||||
import tools.process_registry as pr_module
|
||||
from gateway.session import SessionSource
|
||||
|
||||
sessions = [
|
||||
SimpleNamespace(
|
||||
output_buffer="done\n", exited=True, exit_code=0, command="echo test"
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
|
||||
|
||||
async def _instant_sleep(*_a, **_kw):
|
||||
pass
|
||||
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
||||
|
||||
runner = GatewayRunner(GatewayConfig())
|
||||
adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
|
||||
runner.adapters[Platform.TELEGRAM] = adapter
|
||||
runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
|
||||
origin=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-100",
|
||||
chat_type="group",
|
||||
thread_id="42",
|
||||
user_id="user-42",
|
||||
user_name="alice",
|
||||
)
|
||||
)
|
||||
|
||||
watcher = {
|
||||
"session_id": "proc_test_internal",
|
||||
"check_interval": 0,
|
||||
"session_key": "agent:main:telegram:group:-100:42",
|
||||
"platform": "telegram",
|
||||
"chat_id": "-100",
|
||||
"thread_id": "42",
|
||||
"notify_on_complete": True,
|
||||
}
|
||||
|
||||
await runner._run_process_watcher(watcher)
|
||||
|
||||
assert adapter.handle_message.await_count == 1
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.internal is True
|
||||
assert event.source.platform == Platform.TELEGRAM
|
||||
assert event.source.chat_id == "-100"
|
||||
assert event.source.chat_type == "group"
|
||||
assert event.source.thread_id == "42"
|
||||
assert event.source.user_id == "user-42"
|
||||
assert event.source.user_name == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_user_id_skips_pairing(monkeypatch, tmp_path):
|
||||
"""A non-internal event with user_id=None should be silently dropped."""
|
||||
|
||||
@@ -108,6 +108,9 @@ def _make_fake_mautrix():
|
||||
def add_event_handler(self, event_type, handler):
|
||||
self._event_handlers.setdefault(event_type, []).append(handler)
|
||||
|
||||
def add_dispatcher(self, dispatcher_type):
|
||||
pass
|
||||
|
||||
class InternalEventType:
|
||||
INVITE = "internal.invite"
|
||||
|
||||
@@ -115,6 +118,14 @@ def _make_fake_mautrix():
|
||||
mautrix_client.InternalEventType = InternalEventType
|
||||
mautrix.client = mautrix_client
|
||||
|
||||
# --- mautrix.client.dispatcher ---
|
||||
mautrix_client_dispatcher = types.ModuleType("mautrix.client.dispatcher")
|
||||
|
||||
class MembershipEventDispatcher:
|
||||
pass
|
||||
|
||||
mautrix_client_dispatcher.MembershipEventDispatcher = MembershipEventDispatcher
|
||||
|
||||
# --- mautrix.client.state_store ---
|
||||
mautrix_client_state_store = types.ModuleType("mautrix.client.state_store")
|
||||
|
||||
@@ -163,6 +174,19 @@ def _make_fake_mautrix():
|
||||
|
||||
mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore
|
||||
|
||||
# --- mautrix.crypto.attachments ---
|
||||
mautrix_crypto_attachments = types.ModuleType("mautrix.crypto.attachments")
|
||||
|
||||
def encrypt_attachment(data):
|
||||
encrypted_file = MagicMock()
|
||||
encrypted_file.serialize.return_value = {
|
||||
"key": {"k": "testkey"}, "iv": "testiv",
|
||||
"hashes": {"sha256": "testhash"}, "v": "v2",
|
||||
}
|
||||
return (b"ciphertext_" + data, encrypted_file)
|
||||
|
||||
mautrix_crypto_attachments.encrypt_attachment = encrypt_attachment
|
||||
|
||||
# --- mautrix.crypto.store.asyncpg ---
|
||||
mautrix_crypto_store_asyncpg = types.ModuleType("mautrix.crypto.store.asyncpg")
|
||||
|
||||
@@ -200,8 +224,10 @@ def _make_fake_mautrix():
|
||||
"mautrix.api": mautrix_api,
|
||||
"mautrix.types": mautrix_types,
|
||||
"mautrix.client": mautrix_client,
|
||||
"mautrix.client.dispatcher": mautrix_client_dispatcher,
|
||||
"mautrix.client.state_store": mautrix_client_state_store,
|
||||
"mautrix.crypto": mautrix_crypto,
|
||||
"mautrix.crypto.attachments": mautrix_crypto_attachments,
|
||||
"mautrix.crypto.store": mautrix_crypto_store,
|
||||
"mautrix.crypto.store.asyncpg": mautrix_crypto_store_asyncpg,
|
||||
"mautrix.util": mautrix_util,
|
||||
@@ -213,15 +239,6 @@ def _make_fake_mautrix():
|
||||
# Platform & Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixPlatformEnum:
|
||||
def test_matrix_enum_exists(self):
|
||||
assert Platform.MATRIX.value == "matrix"
|
||||
|
||||
def test_matrix_in_platform_list(self):
|
||||
platforms = [p.value for p in Platform]
|
||||
assert "matrix" in platforms
|
||||
|
||||
|
||||
class TestMatrixConfigLoading:
|
||||
def test_apply_env_overrides_with_access_token(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")
|
||||
@@ -335,6 +352,39 @@ def _make_adapter():
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typing indicator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixTypingIndicator:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.set_typing = AsyncMock()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_clears_matrix_typing_state(self):
|
||||
"""stop_typing() should send typing=false instead of waiting for timeout expiry."""
|
||||
from gateway.platforms.matrix import RoomID
|
||||
|
||||
await self.adapter.stop_typing("!room:example.org")
|
||||
|
||||
self.adapter._client.set_typing.assert_awaited_once_with(
|
||||
RoomID("!room:example.org"),
|
||||
timeout=0,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_no_client_is_noop(self):
|
||||
self.adapter._client = None
|
||||
await self.adapter.stop_typing("!room:example.org") # should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_suppresses_exceptions(self):
|
||||
self.adapter._client.set_typing = AsyncMock(side_effect=Exception("network"))
|
||||
await self.adapter.stop_typing("!room:example.org") # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mxc:// URL conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -812,6 +862,41 @@ class TestMatrixAccessTokenAuth:
|
||||
await adapter.disconnect()
|
||||
|
||||
|
||||
class TestDeviceKeyReVerification:
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_fails_when_server_keys_mismatch_after_upload(self):
|
||||
"""share_keys() succeeds but server still has old keys -> should return False."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.mxid = "@bot:example.org"
|
||||
mock_client.device_id = "TESTDEVICE"
|
||||
|
||||
# First query: keys missing -> triggers share_keys
|
||||
# Second query: keys still don't match -> should fail
|
||||
mock_keys_missing = MagicMock()
|
||||
mock_keys_missing.device_keys = {"@bot:example.org": {}}
|
||||
|
||||
mock_keys_mismatch = MagicMock()
|
||||
mock_device = MagicMock()
|
||||
mock_device.keys = {"ed25519:TESTDEVICE": "server_old_key"}
|
||||
mock_keys_mismatch.device_keys = {"@bot:example.org": {"TESTDEVICE": mock_device}}
|
||||
|
||||
mock_client.query_keys = AsyncMock(side_effect=[mock_keys_missing, mock_keys_mismatch])
|
||||
|
||||
mock_olm = MagicMock()
|
||||
mock_olm.account = MagicMock()
|
||||
mock_olm.account.shared = False
|
||||
mock_olm.account.identity_keys = {"ed25519": "local_new_key"}
|
||||
mock_olm.share_keys = AsyncMock()
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
result = await adapter._verify_device_keys_on_server(mock_client, mock_olm)
|
||||
|
||||
assert result is False
|
||||
mock_olm.share_keys.assert_awaited_once()
|
||||
|
||||
|
||||
class TestMatrixE2EEHardFail:
|
||||
"""connect() must refuse to start when E2EE is requested but deps are missing."""
|
||||
|
||||
@@ -1116,6 +1201,56 @@ class TestMatrixSyncLoop:
|
||||
mock_sync_store.put_next_batch.assert_awaited_once_with("s1234")
|
||||
|
||||
|
||||
class TestMatrixUploadAndSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_unencrypted_room_uses_plain_url(self):
|
||||
"""Unencrypted rooms should use plain 'url' key."""
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = True
|
||||
mock_client = MagicMock()
|
||||
mock_client.crypto = object()
|
||||
mock_client.state_store = MagicMock()
|
||||
mock_client.state_store.is_encrypted = AsyncMock(return_value=False)
|
||||
mock_client.upload_media = AsyncMock(return_value="mxc://example.org/plain")
|
||||
mock_client.send_message_event = AsyncMock(return_value="$event")
|
||||
adapter._client = mock_client
|
||||
|
||||
result = await adapter._upload_and_send(
|
||||
"!room:example.org", b"hello", "test.txt", "text/plain", "m.file",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
sent = mock_client.send_message_event.await_args.args[2]
|
||||
assert sent["url"] == "mxc://example.org/plain"
|
||||
assert "file" not in sent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_encrypted_room_uses_file_payload(self):
|
||||
"""Encrypted rooms should use 'file' key with crypto metadata."""
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = True
|
||||
mock_client = MagicMock()
|
||||
mock_client.crypto = object()
|
||||
mock_client.state_store = MagicMock()
|
||||
mock_client.state_store.is_encrypted = AsyncMock(return_value=True)
|
||||
mock_client.upload_media = AsyncMock(return_value="mxc://example.org/enc")
|
||||
mock_client.send_message_event = AsyncMock(return_value="$event")
|
||||
adapter._client = mock_client
|
||||
|
||||
result = await adapter._upload_and_send(
|
||||
"!room:example.org", b"secret", "secret.txt", "text/plain", "m.file",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Should have uploaded ciphertext, not plaintext
|
||||
uploaded_data = mock_client.upload_media.await_args.args[0]
|
||||
assert uploaded_data != b"secret"
|
||||
sent = mock_client.send_message_event.await_args.args[2]
|
||||
assert "url" not in sent
|
||||
assert "file" in sent
|
||||
assert sent["file"]["url"] == "mxc://example.org/enc"
|
||||
|
||||
|
||||
class TestMatrixEncryptedSendFallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_retries_after_e2ee_error(self):
|
||||
@@ -1142,128 +1277,24 @@ class TestMatrixEncryptedSendFallback:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: MegolmEvent key request + buffering via _on_encrypted_event
|
||||
# E2EE: _joined_rooms reference preservation for CryptoStateStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixMegolmEventHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypted_event_buffers_for_retry(self):
|
||||
"""_on_encrypted_event should buffer undecrypted events for retry."""
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
class TestJoinedRoomsReference:
|
||||
def test_joined_rooms_reference_preserved_after_reassignment(self):
|
||||
"""_CryptoStateStore must see updates after initial sync populates rooms."""
|
||||
from gateway.platforms.matrix import _CryptoStateStore
|
||||
|
||||
fake_event = MagicMock()
|
||||
fake_event.room_id = "!room:example.org"
|
||||
fake_event.event_id = "$encrypted_event"
|
||||
fake_event.sender = "@alice:example.org"
|
||||
joined = set()
|
||||
store = _CryptoStateStore(MagicMock(), joined)
|
||||
|
||||
await adapter._on_encrypted_event(fake_event)
|
||||
# Simulate what connect() should do: mutate in place, not reassign.
|
||||
joined.clear()
|
||||
joined.update(["!room1:example.org", "!room2:example.org"])
|
||||
|
||||
# Should have buffered the event
|
||||
assert len(adapter._pending_megolm) == 1
|
||||
room_id, event, ts = adapter._pending_megolm[0]
|
||||
assert room_id == "!room:example.org"
|
||||
assert event is fake_event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypted_event_buffer_capped(self):
|
||||
"""Buffer should not grow past _MAX_PENDING_EVENTS."""
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
|
||||
from gateway.platforms.matrix import _MAX_PENDING_EVENTS
|
||||
|
||||
for i in range(_MAX_PENDING_EVENTS + 10):
|
||||
evt = MagicMock()
|
||||
evt.room_id = "!room:example.org"
|
||||
evt.event_id = f"$event_{i}"
|
||||
evt.sender = "@alice:example.org"
|
||||
await adapter._on_encrypted_event(evt)
|
||||
|
||||
assert len(adapter._pending_megolm) == _MAX_PENDING_EVENTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2EE: Retry pending decryptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixRetryPendingDecryptions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_decryption_routes_to_handler(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._startup_ts = 0.0
|
||||
adapter._dm_rooms = {}
|
||||
|
||||
fake_encrypted = MagicMock()
|
||||
fake_encrypted.event_id = "$encrypted"
|
||||
|
||||
decrypted_event = MagicMock()
|
||||
|
||||
mock_crypto = MagicMock()
|
||||
mock_crypto.decrypt_megolm_event = AsyncMock(return_value=decrypted_event)
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.crypto = mock_crypto
|
||||
adapter._client = fake_client
|
||||
|
||||
now = time.time()
|
||||
adapter._pending_megolm = [("!room:ex.org", fake_encrypted, now)]
|
||||
|
||||
with patch.object(adapter, "_on_room_message", AsyncMock()) as mock_handler:
|
||||
await adapter._retry_pending_decryptions()
|
||||
mock_handler.assert_awaited_once_with(decrypted_event)
|
||||
|
||||
# Buffer should be empty now
|
||||
assert len(adapter._pending_megolm) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_still_undecryptable_stays_in_buffer(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
fake_encrypted = MagicMock()
|
||||
fake_encrypted.event_id = "$still_encrypted"
|
||||
|
||||
mock_crypto = MagicMock()
|
||||
mock_crypto.decrypt_megolm_event = AsyncMock(side_effect=Exception("missing key"))
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.crypto = mock_crypto
|
||||
adapter._client = fake_client
|
||||
|
||||
now = time.time()
|
||||
adapter._pending_megolm = [("!room:ex.org", fake_encrypted, now)]
|
||||
|
||||
await adapter._retry_pending_decryptions()
|
||||
|
||||
assert len(adapter._pending_megolm) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_events_dropped(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
from gateway.platforms.matrix import _PENDING_EVENT_TTL
|
||||
|
||||
fake_event = MagicMock()
|
||||
fake_event.event_id = "$old_event"
|
||||
|
||||
mock_crypto = MagicMock()
|
||||
fake_client = MagicMock()
|
||||
fake_client.crypto = mock_crypto
|
||||
adapter._client = fake_client
|
||||
|
||||
# Timestamp well past TTL
|
||||
old_ts = time.time() - _PENDING_EVENT_TTL - 60
|
||||
adapter._pending_megolm = [("!room:ex.org", fake_event, old_ts)]
|
||||
|
||||
await adapter._retry_pending_decryptions()
|
||||
|
||||
# Should have been dropped
|
||||
assert len(adapter._pending_megolm) == 0
|
||||
import asyncio
|
||||
rooms = asyncio.get_event_loop().run_until_complete(store.find_shared_rooms("@user:ex"))
|
||||
assert set(rooms) == {"!room1:example.org", "!room2:example.org"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1331,11 +1362,70 @@ class TestMatrixEncryptedEventHandler:
|
||||
handler_calls = mock_client.add_event_handler.call_args_list
|
||||
registered_types = [call.args[0] for call in handler_calls]
|
||||
|
||||
# Should have registered handlers for ROOM_MESSAGE, REACTION, INVITE, and ROOM_ENCRYPTED
|
||||
assert len(handler_calls) >= 4 # At minimum these four
|
||||
# Should have registered handlers for ROOM_MESSAGE, REACTION, INVITE
|
||||
assert len(handler_calls) >= 3
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_on_stale_otk_conflict(self):
|
||||
"""connect() must refuse E2EE when OTK upload hits 'already exists'."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="syt_test_token",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org",
|
||||
"encryption": True,
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
|
||||
fake_mautrix_mods = _make_fake_mautrix()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.mxid = "@bot:example.org"
|
||||
mock_client.device_id = None
|
||||
mock_client.state_store = MagicMock()
|
||||
mock_client.sync_store = MagicMock()
|
||||
mock_client.crypto = None
|
||||
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
||||
mock_client.add_event_handler = MagicMock()
|
||||
mock_client.add_dispatcher = MagicMock()
|
||||
mock_client.query_keys = AsyncMock(return_value={
|
||||
"device_keys": {"@bot:example.org": {"DEV123": {
|
||||
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
||||
}}},
|
||||
})
|
||||
mock_client.api = MagicMock()
|
||||
mock_client.api.token = "syt_test_token"
|
||||
mock_client.api.session = MagicMock()
|
||||
mock_client.api.session.close = AsyncMock()
|
||||
|
||||
# share_keys succeeds on first call (from _verify_device_keys_on_server),
|
||||
# then raises "already exists" on the proactive OTK flush in connect().
|
||||
mock_olm = MagicMock()
|
||||
mock_olm.load = AsyncMock()
|
||||
mock_olm.share_keys = AsyncMock(
|
||||
side_effect=[None, Exception("One time key signed_curve25519:AAAAAQ already exists")]
|
||||
)
|
||||
mock_olm.share_keys_min_trust = None
|
||||
mock_olm.send_keys_min_trust = None
|
||||
mock_olm.account = MagicMock()
|
||||
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
||||
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disconnect
|
||||
@@ -1717,16 +1807,49 @@ class TestMatrixReadReceipts:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepted_message_schedules_read_receipt(self):
|
||||
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
||||
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
self.adapter._background_read_receipt = MagicMock()
|
||||
|
||||
ctx = await self.adapter._resolve_message_context(
|
||||
room_id="!room:ex",
|
||||
sender="@alice:ex",
|
||||
event_id="$event1",
|
||||
body="hello",
|
||||
source_content={"body": "hello"},
|
||||
relates_to={},
|
||||
)
|
||||
|
||||
assert ctx is not None
|
||||
self.adapter._background_read_receipt.assert_called_once_with(
|
||||
"!room:ex", "$event1"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_read_receipt(self):
|
||||
"""send_read_receipt should call client.set_read_markers."""
|
||||
"""send_read_receipt should call mautrix's real read-marker API."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_read_markers = AsyncMock(return_value=None)
|
||||
mock_client.set_fully_read_marker = AsyncMock(return_value=None)
|
||||
self.adapter._client = mock_client
|
||||
|
||||
result = await self.adapter.send_read_receipt("!room:ex", "$event1")
|
||||
assert result is True
|
||||
mock_client.set_read_markers.assert_called_once()
|
||||
mock_client.set_fully_read_marker.assert_awaited_once_with(
|
||||
"!room:ex", "$event1", "$event1"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_read_receipt_falls_back_to_receipt_only(self):
|
||||
"""send_read_receipt should still work with clients lacking read markers."""
|
||||
mock_client = MagicMock(spec=["send_receipt"])
|
||||
mock_client.send_receipt = AsyncMock(return_value=None)
|
||||
self.adapter._client = mock_client
|
||||
|
||||
result = await self.adapter.send_read_receipt("!room:ex", "$event1")
|
||||
assert result is True
|
||||
mock_client.send_receipt.assert_awaited_once_with("!room:ex", "$event1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_receipt_no_client(self):
|
||||
@@ -1829,6 +1952,3 @@ class TestMatrixPresence:
|
||||
self.adapter._client = None
|
||||
result = await self.adapter.set_presence("online")
|
||||
assert result is False
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
# The matrix adapter module is importable without mautrix installed
|
||||
# (module-level imports use try/except with stubs). No need for
|
||||
# module-level mock installation — tests that call adapter methods
|
||||
@@ -159,9 +158,15 @@ class TestStripMention:
|
||||
result = self.adapter._strip_mention("@hermes:example.org help me")
|
||||
assert result == "help me"
|
||||
|
||||
def test_strip_localpart(self):
|
||||
def test_localpart_preserved(self):
|
||||
"""Localpart-only text is no longer stripped — avoids false positives in paths."""
|
||||
result = self.adapter._strip_mention("hermes help me")
|
||||
assert result == "help me"
|
||||
assert result == "hermes help me"
|
||||
|
||||
def test_localpart_in_path_preserved(self):
|
||||
"""Localpart inside a file path must not be damaged."""
|
||||
result = self.adapter._strip_mention("read /home/hermes/config.yaml")
|
||||
assert result == "read /home/hermes/config.yaml"
|
||||
|
||||
def test_strip_returns_empty_for_mention_only(self):
|
||||
result = self.adapter._strip_mention("@hermes:example.org")
|
||||
@@ -273,8 +278,8 @@ async def test_require_mention_dm_always_responds(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_strips_mention(monkeypatch):
|
||||
"""DMs strip mention from body, matching Discord behavior."""
|
||||
async def test_dm_strips_full_mxid(monkeypatch):
|
||||
"""DMs strip the full MXID from body when require_mention is on (default)."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
@@ -289,6 +294,23 @@ async def test_dm_strips_mention(monkeypatch):
|
||||
assert msg.text == "help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_preserves_localpart_in_body(monkeypatch):
|
||||
"""DMs no longer strip bare localpart — only the full MXID is removed."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hermes help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "hermes help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_mention_passes_empty_string(monkeypatch):
|
||||
"""A message that is only a mention should pass through as empty, not be dropped."""
|
||||
@@ -309,7 +331,9 @@ async def test_bare_mention_passes_empty_string(monkeypatch):
|
||||
async def test_require_mention_free_response_room(monkeypatch):
|
||||
"""Free-response rooms bypass mention requirement."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org")
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org"
|
||||
)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
@@ -351,6 +375,22 @@ async def test_require_mention_disabled(monkeypatch):
|
||||
assert msg.text == "hello without mention"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_disabled_skips_stripping(monkeypatch):
|
||||
"""MATRIX_REQUIRE_MENTION=false: mention text is NOT stripped from body."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "@hermes:example.org help me"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-thread in _on_room_message
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -442,8 +482,10 @@ class TestThreadPersistence:
|
||||
def test_empty_state_file(self, tmp_path, monkeypatch):
|
||||
"""No state file → empty set."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: tmp_path / "matrix_threads.json",
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -452,9 +494,11 @@ class TestThreadPersistence:
|
||||
def test_track_thread_persists(self, tmp_path, monkeypatch):
|
||||
"""mark() writes to disk."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -466,10 +510,12 @@ class TestThreadPersistence:
|
||||
def test_threads_survive_reload(self, tmp_path, monkeypatch):
|
||||
"""Persisted threads are loaded by a new adapter instance."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
state_path.write_text(json.dumps(["$t1", "$t2"]))
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -479,9 +525,11 @@ class TestThreadPersistence:
|
||||
def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
|
||||
"""Thread set is trimmed to max_tracked."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
@@ -604,6 +652,7 @@ class TestMatrixConfigBridge:
|
||||
}
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
@@ -613,18 +662,27 @@ class TestMatrixConfigBridge:
|
||||
yaml_cfg = yaml.safe_load(config_file.read_text())
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower())
|
||||
if "require_mention" in matrix_cfg and not os.getenv(
|
||||
"MATRIX_REQUIRE_MENTION"
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
|
||||
)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc))
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower())
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower()
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_REQUIRE_MENTION") == "false"
|
||||
assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org"
|
||||
assert (
|
||||
os.getenv("MATRIX_FREE_RESPONSE_ROOMS")
|
||||
== "!room1:example.org,!room2:example.org"
|
||||
)
|
||||
assert os.getenv("MATRIX_AUTO_THREAD") == "false"
|
||||
|
||||
def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
|
||||
@@ -632,6 +690,7 @@ class TestMatrixConfigBridge:
|
||||
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
yaml_content = {"matrix": {"dm_mention_threads": True}}
|
||||
@@ -641,8 +700,13 @@ class TestMatrixConfigBridge:
|
||||
yaml_cfg = yaml.safe_load(config_file.read_text())
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", str(matrix_cfg["dm_mention_threads"]).lower())
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv(
|
||||
"MATRIX_DM_MENTION_THREADS"
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_DM_MENTION_THREADS",
|
||||
str(matrix_cfg["dm_mention_threads"]).lower(),
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
|
||||
|
||||
@@ -651,9 +715,12 @@ class TestMatrixConfigBridge:
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
|
||||
|
||||
import os
|
||||
|
||||
yaml_cfg = {"matrix": {"require_mention": False}}
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower())
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_REQUIRE_MENTION") == "true"
|
||||
|
||||
@@ -184,8 +184,14 @@ class TestMatrixVoiceMessageDetection:
|
||||
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_audio_has_http_url(self):
|
||||
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
||||
async def test_regular_audio_is_cached_locally(self):
|
||||
"""Regular audio uploads are cached locally for downstream tool access.
|
||||
|
||||
Since PR #bec02f37 (encrypted-media caching refactor), all media
|
||||
types — photo, audio, video, document — are cached locally when
|
||||
received so tools can read them as real files. This applies equally
|
||||
to voice messages and regular audio.
|
||||
"""
|
||||
event = _make_audio_event(is_voice=False)
|
||||
|
||||
captured_event = None
|
||||
@@ -200,10 +206,10 @@ class TestMatrixVoiceMessageDetection:
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should be HTTP URL, not local path
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download_media.assert_not_awaited()
|
||||
# Should be a local path, not an HTTP URL.
|
||||
assert not captured_event.media_urls[0].startswith("http"), \
|
||||
f"Regular audio should be cached locally, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download_media.assert_awaited_once()
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
|
||||
|
||||
@@ -12,15 +12,6 @@ from gateway.config import Platform, PlatformConfig
|
||||
# Platform & Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMattermostPlatformEnum:
|
||||
def test_mattermost_enum_exists(self):
|
||||
assert Platform.MATTERMOST.value == "mattermost"
|
||||
|
||||
def test_mattermost_in_platform_list(self):
|
||||
platforms = [p.value for p in Platform]
|
||||
assert "mattermost" in platforms
|
||||
|
||||
|
||||
class TestMattermostConfigLoading:
|
||||
def test_apply_env_overrides_mattermost(self, monkeypatch):
|
||||
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
|
||||
@@ -46,17 +37,6 @@ class TestMattermostConfigLoading:
|
||||
|
||||
assert Platform.MATTERMOST not in config.platforms
|
||||
|
||||
def test_connected_platforms_includes_mattermost(self, monkeypatch):
|
||||
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
|
||||
monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com")
|
||||
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
config = GatewayConfig()
|
||||
_apply_env_overrides(config)
|
||||
|
||||
connected = config.get_connected_platforms()
|
||||
assert Platform.MATTERMOST in connected
|
||||
|
||||
def test_mattermost_home_channel(self, monkeypatch):
|
||||
monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123")
|
||||
monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com")
|
||||
|
||||
89
tests/gateway/test_message_deduplicator.py
Normal file
89
tests/gateway/test_message_deduplicator.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for MessageDeduplicator TTL enforcement (#10306).
|
||||
|
||||
Previously, is_duplicate() returned True for any previously seen ID without
|
||||
checking its age — expired entries were only purged when cache size exceeded
|
||||
max_size. Normal workloads never overflowed, so messages stayed "duplicate"
|
||||
forever.
|
||||
|
||||
The fix checks TTL at query time: if the entry's timestamp plus TTL is in
|
||||
the past, the entry is treated as expired and the message is allowed through.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
|
||||
|
||||
class TestMessageDeduplicatorTTL:
|
||||
"""TTL-based expiration must work regardless of cache size."""
|
||||
|
||||
def test_duplicate_within_ttl(self):
|
||||
"""Same message within TTL window is duplicate."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=60)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
assert dedup.is_duplicate("msg-1") is True
|
||||
|
||||
def test_not_duplicate_after_ttl_expires(self):
|
||||
"""Same message AFTER TTL expires should NOT be duplicate."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=5)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
|
||||
# Fast-forward time past TTL
|
||||
dedup._seen["msg-1"] = time.time() - 10 # 10s ago, TTL is 5s
|
||||
assert dedup.is_duplicate("msg-1") is False, \
|
||||
"Expired entry should not be treated as duplicate"
|
||||
|
||||
def test_expired_entry_gets_refreshed(self):
|
||||
"""After an expired entry is allowed through, it should be re-tracked."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=5)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
|
||||
# Expire the entry
|
||||
dedup._seen["msg-1"] = time.time() - 10
|
||||
|
||||
# Should be allowed through (expired)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
# Now should be duplicate again (freshly tracked)
|
||||
assert dedup.is_duplicate("msg-1") is True
|
||||
|
||||
def test_different_messages_not_confused(self):
|
||||
"""Different message IDs are independent."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=60)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
assert dedup.is_duplicate("msg-2") is False
|
||||
assert dedup.is_duplicate("msg-1") is True
|
||||
assert dedup.is_duplicate("msg-2") is True
|
||||
|
||||
def test_empty_id_never_duplicate(self):
|
||||
"""Empty/None message IDs are never treated as duplicate."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=60)
|
||||
assert dedup.is_duplicate("") is False
|
||||
assert dedup.is_duplicate("") is False
|
||||
|
||||
def test_max_size_eviction_prunes_expired(self):
|
||||
"""Cache pruning on overflow removes expired entries."""
|
||||
dedup = MessageDeduplicator(max_size=5, ttl_seconds=60)
|
||||
# Add 6 entries, with the first 3 expired
|
||||
now = time.time()
|
||||
for i in range(3):
|
||||
dedup._seen[f"old-{i}"] = now - 120 # expired (2 min ago, TTL 60s)
|
||||
for i in range(3):
|
||||
dedup.is_duplicate(f"new-{i}")
|
||||
# Now we have 6 entries. Next insert triggers pruning.
|
||||
dedup.is_duplicate("trigger")
|
||||
# The 3 expired entries should be gone, leaving 4 fresh ones
|
||||
assert len(dedup._seen) == 4
|
||||
assert "old-0" not in dedup._seen
|
||||
assert "new-0" in dedup._seen
|
||||
|
||||
def test_ttl_zero_means_no_dedup(self):
|
||||
"""With TTL=0, all entries expire immediately."""
|
||||
dedup = MessageDeduplicator(ttl_seconds=0)
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
# Entry was just added at time.time(), and TTL is 0,
|
||||
# so now - seen_time >= 0 = ttl, meaning it's expired
|
||||
# But time.time() might be the exact same float, so
|
||||
# the check is `now - ts < ttl` which is `0 < 0` = False
|
||||
# This means TTL=0 effectively disables dedup
|
||||
assert dedup.is_duplicate("msg-1") is False
|
||||
212
tests/gateway/test_pending_drain_race.py
Normal file
212
tests/gateway/test_pending_drain_race.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Regression tests: pending-drain + finally-cleanup races must not spawn
|
||||
duplicate agents OR silently drop messages that arrived during cleanup.
|
||||
|
||||
Two related races in gateway/platforms/base.py:_process_message_background:
|
||||
|
||||
1. Pending-drain path (previous line 1931):
|
||||
``del self._active_sessions[session_key]`` opened a window where a
|
||||
concurrent inbound message could pass the Level-1 guard, spawn its
|
||||
own _process_message_background, and run simultaneously with the
|
||||
recursive drain. Two agents on one session_key = duplicate responses.
|
||||
|
||||
2. Finally-cleanup path (previous line 1990-1991):
|
||||
Between the awaits in finally (typing_task, stop_typing) and the
|
||||
``del self._active_sessions[session_key]``, a new message could
|
||||
land in _pending_messages. The del ran anyway, and the message was
|
||||
silently dropped — user never got a reply.
|
||||
|
||||
Fix: keep the _active_sessions entry live across the turn chain and
|
||||
clear the Event instead of deleting; in finally, drain any
|
||||
late-arrival pending message by spawning a task instead of
|
||||
dropping it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
)
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
class _StubAdapter(BasePlatformAdapter):
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, text, **kwargs):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {}
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = _StubAdapter(PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM)
|
||||
adapter._send_with_retry = AsyncMock(return_value=None)
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_event(text="hi", chat_id="42"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
def _sk(chat_id="42"):
|
||||
return build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_drain_keeps_active_session_guard_live():
|
||||
"""Fix for R5: during pending-drain cleanup, _active_sessions must stay
|
||||
populated so concurrent inbound messages can't spawn a duplicate
|
||||
_process_message_background. We only CLEAR the Event, never delete."""
|
||||
adapter = _make_adapter()
|
||||
sk = _sk()
|
||||
|
||||
# Register a slow handler so the agent is "mid-processing" when the
|
||||
# pending message arrives.
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
|
||||
async def handler(event):
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
return "done"
|
||||
|
||||
adapter._message_handler = handler
|
||||
|
||||
# Spawn M1 through handle_message.
|
||||
await adapter.handle_message(_make_event(text="M1"))
|
||||
|
||||
# Wait until M1 is actively running inside the handler.
|
||||
await asyncio.wait_for(first_started.wait(), timeout=1.0)
|
||||
|
||||
# Assert: session is active.
|
||||
assert sk in adapter._active_sessions
|
||||
active_event = adapter._active_sessions[sk]
|
||||
|
||||
# Simulate pending message (M2) queued while M1 runs.
|
||||
adapter._pending_messages[sk] = _make_event(text="M2")
|
||||
|
||||
# Release M1 — pending-drain block now runs. During its cleanup
|
||||
# awaits, _active_sessions[sk] must remain populated (same object
|
||||
# reference) so any M3 arriving in that window hits the busy-handler.
|
||||
release_first.set()
|
||||
|
||||
# Give the drain a moment to execute its .clear() + await typing_task
|
||||
# without letting it fully finish the recursive call.
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Across the drain transition, the Event object must be the SAME
|
||||
# reference (not replaced, not deleted). If del happened, the key
|
||||
# would be missing briefly; if a new Event was installed, the
|
||||
# identity would differ.
|
||||
assert sk in adapter._active_sessions, (
|
||||
"_active_sessions[session_key] was deleted during pending-drain — "
|
||||
"opens a window for duplicate-agent spawn"
|
||||
)
|
||||
assert adapter._active_sessions[sk] is active_event, (
|
||||
"_active_sessions[session_key] was replaced during pending-drain — "
|
||||
"the old Event may have waiters that now won't be signaled"
|
||||
)
|
||||
|
||||
# Finish drain.
|
||||
await asyncio.sleep(0.1)
|
||||
await adapter.cancel_background_tasks()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finally_cleanup_drains_late_arrival_pending():
|
||||
"""Fix for R6: if a message lands in _pending_messages during the
|
||||
finally-block cleanup awaits, the finally must spawn a drain task
|
||||
instead of deleting _active_sessions and dropping the message."""
|
||||
adapter = _make_adapter()
|
||||
sk = _sk()
|
||||
|
||||
processed = []
|
||||
|
||||
async def handler(event):
|
||||
processed.append(event.text)
|
||||
return "ok"
|
||||
|
||||
adapter._message_handler = handler
|
||||
|
||||
# Instrument stop_typing to inject a late-arrival pending message
|
||||
# during the finally-block await window. This exactly simulates the
|
||||
# R6 race: the message arrives after the response has been sent but
|
||||
# before _active_sessions is deleted.
|
||||
original_stop = adapter.stop_typing if hasattr(adapter, "stop_typing") else None
|
||||
|
||||
injected = {"done": False}
|
||||
|
||||
async def stop_typing_injects_pending(*args, **kwargs):
|
||||
# Yield so the injection happens mid-await.
|
||||
await asyncio.sleep(0)
|
||||
if not injected["done"]:
|
||||
adapter._pending_messages[sk] = _make_event(text="LATE")
|
||||
injected["done"] = True
|
||||
if original_stop:
|
||||
return await original_stop(*args, **kwargs)
|
||||
return None
|
||||
|
||||
adapter.stop_typing = stop_typing_injects_pending
|
||||
|
||||
# Send M1.
|
||||
await adapter.handle_message(_make_event(text="M1"))
|
||||
|
||||
# Drain: wait for M1 to finish and the late-drain task to process LATE.
|
||||
for _ in range(50): # up to ~0.5s
|
||||
if "LATE" in processed:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await adapter.cancel_background_tasks()
|
||||
|
||||
assert "M1" in processed, "M1 was not processed"
|
||||
assert "LATE" in processed, (
|
||||
"Late-arrival pending message was silently dropped — finally "
|
||||
"cleanup should have spawned a drain task"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pending_cleans_up_normally():
|
||||
"""Regression guard: when no pending message exists, the finally
|
||||
block must still delete _active_sessions as before (no leak)."""
|
||||
adapter = _make_adapter()
|
||||
sk = _sk()
|
||||
|
||||
async def handler(event):
|
||||
return "ok"
|
||||
|
||||
adapter._message_handler = handler
|
||||
|
||||
await adapter.handle_message(_make_event(text="solo"))
|
||||
|
||||
# Wait for background task to finish.
|
||||
for _ in range(50):
|
||||
if sk not in adapter._active_sessions:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert sk not in adapter._active_sessions, (
|
||||
"_active_sessions was not cleaned up after a normal turn with no pending"
|
||||
)
|
||||
assert sk not in adapter._pending_messages
|
||||
|
||||
await adapter.cancel_background_tasks()
|
||||
72
tests/gateway/test_pending_event_none.py
Normal file
72
tests/gateway/test_pending_event_none.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for pending follow-up extraction in recursive _run_agent calls.
|
||||
|
||||
When pending_event is None (Path B: pending comes from interrupt_message),
|
||||
accessing pending_event.channel_prompt previously raised AttributeError.
|
||||
This verifies the fix: channel_prompt is captured inside the
|
||||
`if pending_event is not None:` block and falls back to None otherwise.
|
||||
|
||||
Also verifies that internal control interrupt reasons like "Stop requested"
|
||||
do not get recycled into the pending-user-message follow-up path.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway.run import _is_control_interrupt_message
|
||||
|
||||
|
||||
def _extract_channel_prompt(pending_event):
|
||||
"""Reproduce the fixed logic from gateway/run.py.
|
||||
|
||||
Mirrors the variable-capture pattern used before the recursive
|
||||
_run_agent call so we can test both paths without a full runner.
|
||||
"""
|
||||
next_channel_prompt = None
|
||||
if pending_event is not None:
|
||||
next_channel_prompt = getattr(pending_event, "channel_prompt", None)
|
||||
return next_channel_prompt
|
||||
|
||||
|
||||
def _extract_pending_text(interrupted, pending_event, interrupt_message):
|
||||
"""Reproduce the fixed pending-text selection from gateway/run.py."""
|
||||
if interrupted and pending_event is None and interrupt_message:
|
||||
if _is_control_interrupt_message(interrupt_message):
|
||||
return None
|
||||
return interrupt_message
|
||||
return None
|
||||
|
||||
|
||||
class TestPendingEventNoneChannelPrompt:
|
||||
"""Guard against AttributeError when pending_event is None."""
|
||||
|
||||
def test_none_pending_event_returns_none_channel_prompt(self):
|
||||
"""Path B: pending_event is None — must not raise AttributeError."""
|
||||
result = _extract_channel_prompt(None)
|
||||
assert result is None
|
||||
|
||||
def test_pending_event_with_channel_prompt_passes_through(self):
|
||||
"""Path A: pending_event present — channel_prompt is forwarded."""
|
||||
event = SimpleNamespace(channel_prompt="You are a helpful bot.")
|
||||
result = _extract_channel_prompt(event)
|
||||
assert result == "You are a helpful bot."
|
||||
|
||||
def test_pending_event_without_channel_prompt_returns_none(self):
|
||||
"""Path A: pending_event present but has no channel_prompt attribute."""
|
||||
event = SimpleNamespace()
|
||||
result = _extract_channel_prompt(event)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestControlInterruptMessages:
|
||||
"""Control interrupt reasons must not become follow-up user input."""
|
||||
|
||||
def test_stop_requested_is_not_treated_as_pending_user_message(self):
|
||||
result = _extract_pending_text(True, None, "Stop requested")
|
||||
assert result is None
|
||||
|
||||
def test_session_reset_requested_is_not_treated_as_pending_user_message(self):
|
||||
result = _extract_pending_text(True, None, "Session reset requested")
|
||||
assert result is None
|
||||
|
||||
def test_real_user_interrupt_message_still_requeues(self):
|
||||
result = _extract_pending_text(True, None, "actually use postgres instead")
|
||||
assert result == "actually use postgres instead"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user