mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.events import (
|
|
INBOUND_META_RUNTIME_CONTROL,
|
|
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
InboundMessage,
|
|
)
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.base import GenerationSettings, LLMResponse
|
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
|
|
|
|
def _message(key: str, content: str) -> InboundMessage:
|
|
return InboundMessage(
|
|
channel="websocket",
|
|
sender_id="user",
|
|
chat_id=key.removeprefix("websocket:"),
|
|
content=content,
|
|
session_key_override=key,
|
|
require_existing_session=True,
|
|
)
|
|
|
|
|
|
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.generation = GenerationSettings()
|
|
provider.chat_with_retry = AsyncMock(
|
|
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
|
|
)
|
|
return AgentLoop(
|
|
bus=MessageBus(),
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
model="test-model",
|
|
cron_service=MagicMock(),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
|
|
loop = _loop(tmp_path, ["first answer", "second answer"])
|
|
loop.context.memory.write_memory("private durable memory")
|
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
|
key = "websocket:transient-test"
|
|
loop.sessions.get_or_create_transient(
|
|
key,
|
|
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
|
)
|
|
|
|
await loop._process_message(_message(key, "first question"))
|
|
await loop._process_message(_message(key, "second question"))
|
|
|
|
calls = loop.provider.chat_with_retry.await_args_list
|
|
assert "private durable memory" not in str(calls[0].kwargs["messages"])
|
|
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
|
|
assert "read_session" in tool_names
|
|
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
|
|
assert "first answer" in str(calls[1].kwargs["messages"])
|
|
session = loop.sessions.get_cached(key)
|
|
assert session is not None
|
|
assert [message["role"] for message in session.messages] == [
|
|
"user",
|
|
"assistant",
|
|
"user",
|
|
"assistant",
|
|
]
|
|
assert loop.sessions.read_session_file(key) is None
|
|
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
|
|
loop = _loop(tmp_path, ["private answer"], unified_session=True)
|
|
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
|
|
durable.add_message("user", "durable question")
|
|
loop.sessions.save(durable)
|
|
key = "websocket:transient-unified"
|
|
transient = loop.sessions.get_or_create_transient(key)
|
|
|
|
await loop._dispatch(_message(key, "private question"))
|
|
|
|
assert [message["content"] for message in transient.messages] == [
|
|
"private question",
|
|
"private answer",
|
|
]
|
|
assert [message["content"] for message in durable.messages] == ["durable question"]
|
|
assert loop.sessions.read_session_file(key) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
|
|
loop = _loop(tmp_path, [])
|
|
key = "websocket:transient-stale"
|
|
loop.sessions.get_or_create_transient(key)
|
|
loop.sessions.invalidate(key)
|
|
|
|
with pytest.raises(RuntimeError, match="required session is not active"):
|
|
await loop._process_message(_message(key, "stale private message"))
|
|
|
|
loop.provider.chat_with_retry.assert_not_awaited()
|
|
assert loop.sessions.read_session_file(key) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
|
|
provider_started = asyncio.Event()
|
|
|
|
async def block_provider(**_kwargs: object) -> LLMResponse:
|
|
provider_started.set()
|
|
await asyncio.Event().wait()
|
|
raise AssertionError("provider blocker unexpectedly released")
|
|
|
|
loop = _loop(tmp_path, [])
|
|
|
|
async def wait_for_discard(key: str) -> None:
|
|
while loop.sessions.get_cached(key) is not None:
|
|
await asyncio.sleep(0)
|
|
|
|
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
|
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
|
|
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
|
|
key = "websocket:transient-cancelled"
|
|
loop.sessions.get_or_create_transient(
|
|
key,
|
|
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
|
)
|
|
run_task = asyncio.create_task(loop.run())
|
|
await loop.bus.publish_inbound(_message(key, "private"))
|
|
await asyncio.wait_for(provider_started.wait(), timeout=2)
|
|
active_task = next(iter(loop._active_tasks[key]))
|
|
|
|
await loop.bus.publish_inbound(
|
|
InboundMessage(
|
|
channel="websocket",
|
|
sender_id="webui",
|
|
chat_id="transient-cancelled",
|
|
content="",
|
|
metadata={
|
|
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
|
},
|
|
session_key_override=key,
|
|
)
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(active_task, timeout=2)
|
|
await asyncio.wait_for(wait_for_discard(key), timeout=2)
|
|
assert loop.sessions.get_cached(key) is None
|
|
|
|
loop.stop()
|
|
await loop.bus.publish_inbound(_message(key, "wake"))
|
|
await asyncio.wait_for(run_task, timeout=2)
|