Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -695,3 +695,44 @@ 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
|
||||
|
||||
@@ -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:
|
||||
@@ -877,6 +879,117 @@ class TestRunJobPerJobOverrides:
|
||||
|
||||
|
||||
class TestRunJobSkillBacked:
|
||||
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
|
||||
job = {
|
||||
"id": "skill-env-job",
|
||||
"name": "skill env test",
|
||||
"prompt": "Use the skill.",
|
||||
"skill": "notion",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
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={
|
||||
"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_env_passthrough()
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
|
||||
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"
|
||||
|
||||
def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path):
|
||||
job = {
|
||||
"id": "skill-job",
|
||||
|
||||
@@ -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,162 @@ 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():
|
||||
"""Thread ID (6th part) is extracted; further parts are ignored."""
|
||||
result = _parse_session_key("agent:main:discord:group:chan123:thread456")
|
||||
assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"}
|
||||
|
||||
|
||||
def test_parse_session_key_with_user_id_part():
|
||||
"""7th part (user_id) is ignored — only up to thread_id is extracted."""
|
||||
result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99")
|
||||
assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -92,6 +92,25 @@ class TestCheckWatchPatterns:
|
||||
assert "disk full" in evt["output"]
|
||||
assert evt["session_id"] == "proc_test_watch"
|
||||
|
||||
def test_match_carries_session_key_and_watcher_routing_metadata(self, registry):
|
||||
session = _make_session(watch_patterns=["ERROR"])
|
||||
session.session_key = "agent:main:telegram:group:-100:42"
|
||||
session.watcher_platform = "telegram"
|
||||
session.watcher_chat_id = "-100"
|
||||
session.watcher_user_id = "u123"
|
||||
session.watcher_user_name = "alice"
|
||||
session.watcher_thread_id = "42"
|
||||
|
||||
registry._check_watch_patterns(session, "ERROR: disk full\n")
|
||||
evt = registry.completion_queue.get_nowait()
|
||||
|
||||
assert evt["session_key"] == "agent:main:telegram:group:-100:42"
|
||||
assert evt["platform"] == "telegram"
|
||||
assert evt["chat_id"] == "-100"
|
||||
assert evt["user_id"] == "u123"
|
||||
assert evt["user_name"] == "alice"
|
||||
assert evt["thread_id"] == "42"
|
||||
|
||||
def test_multiple_patterns(self, registry):
|
||||
"""First matching pattern is reported."""
|
||||
session = _make_session(watch_patterns=["WARN", "ERROR"])
|
||||
|
||||
Reference in New Issue
Block a user