refactor: move MCP lifecycle out of AgentLoop (#5343)

This commit is contained in:
chengyongru
2026-08-12 17:51:04 +08:00
committed by GitHub
parent 686dd0603e
commit 19997d20bb
39 changed files with 1192 additions and 846 deletions
+46 -41
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext
@@ -193,7 +194,11 @@ class TestIdleScanThrottling:
})
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop.from_config(config, provider=provider)
loop = AgentLoop.from_config(
config,
tool_registry=ToolRegistry(),
provider=provider,
)
loop.auto_compact.check_expired = MagicMock()
loop._check_expired_sessions_if_due()
@@ -310,7 +315,7 @@ class TestAutoCompact:
assert loop.auto_compact._is_expired(ts) is True
ts2 = datetime.now() - timedelta(minutes=14, seconds=59)
assert loop.auto_compact._is_expired(ts2) is False
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_is_expired_string_timestamp(self, tmp_path):
@@ -320,7 +325,7 @@ class TestAutoCompact:
assert loop.auto_compact._is_expired(ts) is True
assert loop.auto_compact._is_expired(None) is False
assert loop.auto_compact._is_expired("") is False
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_check_expired_only_archives_expired_sessions(self, tmp_path):
@@ -343,7 +348,7 @@ class TestAutoCompact:
active_after = loop.sessions.get_or_create("cli:active")
assert len(active_after.messages) == 1
assert active_after.messages[0]["content"] == "recent"
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
@@ -367,7 +372,7 @@ class TestAutoCompact:
assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
assert visible[0]["content"] == "msg user 2"
assert visible[-1]["content"] == "msg assistant 5"
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_extends_recent_suffix_to_user_turn(self, tmp_path):
@@ -398,7 +403,7 @@ class TestAutoCompact:
for m in visible
for tc in (m.get("tool_calls") or [])
)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_stores_summary(self, tmp_path):
@@ -422,7 +427,7 @@ class TestAutoCompact:
assert len(session_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_empty_session(self, tmp_path):
@@ -436,7 +441,7 @@ class TestAutoCompact:
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_respects_last_consolidated(self, tmp_path):
@@ -455,7 +460,7 @@ class TestAutoCompact:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert len(archived_messages) == 10
await loop.close_mcp()
await loop.aclose()
class TestAutoCompactIdleDetection:
@@ -474,7 +479,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "old message" for m in session_after.messages)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_triggers_on_idle(self, tmp_path):
@@ -503,7 +508,7 @@ class TestAutoCompactIdleDetection:
for m in session_after.get_history(max_messages=len(session_after.messages))
)
assert any(m["content"] == "new msg" for m in session_after.messages)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_no_auto_compact_when_active(self, tmp_path):
@@ -517,7 +522,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "recent message" for m in session_after.messages)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_does_not_affect_priority_commands(self, tmp_path):
@@ -540,7 +545,7 @@ class TestAutoCompactIdleDetection:
# Session should be untouched since priority commands skip _process_message
session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "old message" for m in session_after.messages)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_with_slash_new(self, tmp_path):
@@ -562,7 +567,7 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 0
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_shortcut_command_persisted_with_command_flag(self, tmp_path):
@@ -581,7 +586,7 @@ class TestAutoCompactIdleDetection:
assert session_after.messages[1]["role"] == "assistant"
assert session_after.messages[1].get("_command") is True
assert AgentLoop._PENDING_USER_TURN_KEY not in session_after.metadata
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_shortcut_command_excluded_from_get_history(self, tmp_path):
@@ -597,7 +602,7 @@ class TestAutoCompactIdleDetection:
assert len(history) == 2
assert all(m["content"] != "/help" for m in history)
assert all(m["content"] != "help text" for m in history)
await loop.close_mcp()
await loop.aclose()
class TestAutoCompactSystemMessages:
@@ -628,7 +633,7 @@ class TestAutoCompactSystemMessages:
m["content"] == "old user 0"
for m in session_after.get_history(max_messages=len(session_after.messages))
)
await loop.close_mcp()
await loop.aclose()
class TestAutoCompactEdgeCases:
@@ -656,7 +661,7 @@ class TestAutoCompactEdgeCases:
# "(nothing)" summary should not be stored
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
@@ -677,7 +682,7 @@ class TestAutoCompactEdgeCases:
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
@@ -709,7 +714,7 @@ class TestAutoCompactEdgeCases:
assert any(m["content"] == "previous message" for m in session_after.messages)
assert any(m["content"] == "interrupted response" for m in session_after.messages)
await loop.close_mcp()
await loop.aclose()
class TestAutoCompactIntegration:
@@ -779,7 +784,7 @@ class TestAutoCompactIntegration:
# The new message should be processed (response exists)
assert response is not None
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path):
@@ -807,7 +812,7 @@ class TestAutoCompactIntegration:
content = str(persisted.get("content", ""))
assert "[Runtime Context" not in content
assert "[/Runtime Context]" not in content
await loop.close_mcp()
await loop.aclose()
class TestProactiveAutoCompact:
@@ -870,7 +875,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 1
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_proactive_archive_on_idle_tick(self, tmp_path):
@@ -897,7 +902,7 @@ class TestProactiveAutoCompact:
entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None
assert entry[0] == "User chatted about old things."
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
@@ -918,7 +923,7 @@ class TestProactiveAutoCompact:
assert _fake_compact.state["count"] == 0
assert "dream:20260602-155256" not in loop.auto_compact._archiving
assert "dream:20260602-155256" not in loop.auto_compact._summaries
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_no_proactive_archive_when_active(self, tmp_path):
@@ -932,7 +937,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 1
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_no_duplicate_archive(self, tmp_path):
@@ -968,7 +973,7 @@ class TestProactiveAutoCompact:
# Clean up
block_forever.set()
await _drain_background_tasks(loop)
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_proactive_archive_error_does_not_block(self, tmp_path):
@@ -989,7 +994,7 @@ class TestProactiveAutoCompact:
# Key should be removed from _archiving (finally block)
assert "cli:test" not in loop.auto_compact._archiving
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_proactive_archive_skips_empty_sessions(self, tmp_path):
@@ -1005,7 +1010,7 @@ class TestProactiveAutoCompact:
# Empty session should not produce a summary
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
@@ -1026,7 +1031,7 @@ class TestProactiveAutoCompact:
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 # All messages preserved
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_archive_after_active_task_completes(self, tmp_path):
@@ -1047,7 +1052,7 @@ class TestProactiveAutoCompact:
# Second tick: task completed, should archive
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
@@ -1083,7 +1088,7 @@ class TestProactiveAutoCompact:
assert len(s2_after.messages) == 12 # Preserved
s3_after = loop.sessions.get_or_create("cli:recent")
assert len(s3_after.messages) == 1 # Preserved
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_no_reschedule_after_successful_archive(self, tmp_path):
@@ -1104,7 +1109,7 @@ class TestProactiveAutoCompact:
# Second tick: should NOT re-schedule because the session has no removable tail.
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
@@ -1124,7 +1129,7 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_session_can_be_compacted_again_after_new_messages(self, tmp_path):
@@ -1155,7 +1160,7 @@ class TestProactiveAutoCompact:
# Second compact cycle should succeed
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
assert _fake_compact.state["count"] == 2
await loop.close_mcp()
await loop.aclose()
class TestSummaryPersistence:
@@ -1182,7 +1187,7 @@ class TestSummaryPersistence:
assert meta is not None
assert meta["text"] == "User said hello."
assert "last_active" in meta
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_summary_recovered_after_restart(self, tmp_path):
@@ -1218,7 +1223,7 @@ class TestSummaryPersistence:
assert "Previous conversation summary" in summary
# _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_metadata_persists_for_restart(self, tmp_path):
@@ -1246,7 +1251,7 @@ class TestSummaryPersistence:
assert "Summary." in summary2
# _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_metadata_cleanup_on_inmemory_path(self, tmp_path):
@@ -1272,7 +1277,7 @@ class TestSummaryPersistence:
assert summary is not None
# _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_new_summary_overrides_old(self, tmp_path):
@@ -1314,7 +1319,7 @@ class TestSummaryPersistence:
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary2 is not None
assert "Second summary." in summary2
await loop.close_mcp()
await loop.aclose()
@pytest.mark.asyncio
async def test_new_command_clears_last_summary(self, tmp_path):
@@ -1342,4 +1347,4 @@ class TestSummaryPersistence:
# After /new, metadata should no longer contain _last_summary
fresh = loop.sessions.get_or_create("cli:test")
assert "_last_summary" not in fresh.metadata
await loop.close_mcp()
await loop.aclose()