import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.loop import AgentLoop from nanobot.agent.tools.context import ( RequestContext, bind_request_context, current_request_context, reset_request_context, ) from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META class _ContextRecordingTool: name = "cron" concurrency_safe = False def __init__(self) -> None: self.contexts: list[dict] = [] self.runtimes: list[object] = [] async def execute(self, **_kwargs) -> str: ctx = current_request_context() assert ctx is not None self.runtimes.append(ctx.runtime) self.contexts.append({ "channel": ctx.channel, "chat_id": ctx.chat_id, "metadata": ctx.metadata, "session_key": ctx.session_key, }) return "created" class _Tools: def __init__(self, tool: _ContextRecordingTool) -> None: self.tool = tool @property def tool_names(self) -> list[str]: return ["cron"] def get(self, name: str): return self.tool if name == "cron" else None def get_definitions(self) -> list: return [] def prepare_call(self, name: str, arguments: dict): return (self.tool, arguments, None) if name == "cron" else (None, arguments, None) @pytest.mark.asyncio async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None: provider = MagicMock() calls = {"n": 0} async def chat_with_retry(**_kwargs): calls["n"] += 1 if calls["n"] == 1: return LLMResponse( content=None, tool_calls=[ToolCallRequest(id="call_1", name="cron", arguments={"action": "add"})], ) return LLMResponse(content="done", tool_calls=[]) provider.chat_with_retry = chat_with_retry provider.get_default_model.return_value = "test-model" loop = AgentLoop( bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model", ) cron = _ContextRecordingTool() loop.tools = _Tools(cron) metadata = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} runtime = loop.llm_runtime() await loop._run_agent_loop( [], runtime=runtime, channel="slack", chat_id="C123", metadata=metadata, session_key="slack:C123:111.222", ) assert cron.contexts[-1] == { "channel": "slack", "chat_id": "C123", "metadata": metadata, "session_key": "slack:C123:111.222", } assert cron.runtimes[-1] is runtime def test_request_context_nested_bind_restores_outer_context() -> None: outer = RequestContext(channel="slack", chat_id="outer", session_key="slack:outer") inner = RequestContext(channel="email", chat_id="inner", session_key="email:inner") outer_token = bind_request_context(outer) try: assert current_request_context() is outer inner_token = bind_request_context(inner) try: assert current_request_context() is inner finally: reset_request_context(inner_token) assert current_request_context() is outer finally: reset_request_context(outer_token) assert current_request_context() is None @pytest.mark.asyncio async def test_request_context_bindings_are_isolated_between_concurrent_tasks() -> None: entered = asyncio.Event() release = asyncio.Event() async def observe(ctx: RequestContext, *, wait_first: bool) -> RequestContext | None: token = bind_request_context(ctx) try: if wait_first: entered.set() await release.wait() else: await entered.wait() release.set() await asyncio.sleep(0) return current_request_context() finally: reset_request_context(token) first = RequestContext(channel="feishu", chat_id="first", session_key="feishu:first") second = RequestContext(channel="telegram", chat_id="second", session_key="telegram:second") observed = await asyncio.gather( observe(first, wait_first=True), observe(second, wait_first=False), ) assert observed == [first, second] assert current_request_context() is None @pytest.mark.asyncio async def test_agent_loop_restores_outer_request_context_after_runner_exception( tmp_path: Path, ) -> None: provider = MagicMock() provider.get_default_model.return_value = "test-model" loop = AgentLoop( bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model", ) outer = RequestContext(channel="test", chat_id="outer", session_key="test:outer") runtime = loop.llm_runtime() async def fail_run(spec): current = current_request_context() assert current is not None assert spec.runtime is runtime assert current.runtime is runtime assert current.channel == "slack" assert current.chat_id == "C123" assert current.session_key == "slack:C123:111.222" assert current.original_user_text == " unchanged user text " raise RuntimeError("runner failed") loop.runner.run = AsyncMock(side_effect=fail_run) outer_token = bind_request_context(outer) try: with pytest.raises(RuntimeError, match="runner failed"): await loop._run_agent_loop( [], runtime=runtime, channel="slack", chat_id="C123", session_key="slack:C123:111.222", original_user_text=" unchanged user text ", ) assert current_request_context() is outer finally: reset_request_context(outer_token) assert current_request_context() is None @pytest.mark.asyncio @pytest.mark.parametrize( ("metadata", "expected"), [ ({}, " original user text "), ({INTERNAL_CONTINUATION_META: True}, None), ], ) async def test_process_message_captures_original_text_before_restore( tmp_path: Path, metadata: dict, expected: str | None, ) -> None: provider = MagicMock() provider.get_default_model.return_value = "test-model" loop = AgentLoop( bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model", ) runtime = loop.llm_runtime() seen: list[tuple[str | None, object]] = [] async def stop_after_capture(ctx) -> str: seen.append((ctx.original_user_text, ctx.runtime)) raise RuntimeError("captured before restore") loop._state_restore = stop_after_capture # type: ignore[method-assign] with pytest.raises(RuntimeError, match="captured before restore"): await loop._process_message( InboundMessage( channel="slack", sender_id="user", chat_id="C123", content=" original user text ", metadata=metadata, ), runtime=runtime, ) assert seen == [(expected, runtime)]