refactor(agent): unify request context routing

This commit is contained in:
chengyongru
2026-07-10 17:54:34 +08:00
committed by Xubin Ren
parent bb3b449e09
commit 42d7ad34a4
20 changed files with 363 additions and 423 deletions
+26 -17
View File
@@ -7,6 +7,7 @@ import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStatusEvent,
@@ -1090,20 +1091,24 @@ async def test_process_direct_skip_user_persist_does_not_save_retry_user(
]
def test_set_tool_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_request_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None
spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined]
loop._set_tool_context(
"discord",
"thread-777",
with request_context(RequestContext(
channel="discord",
chat_id="thread-777",
session_key="discord:parent-456:thread:thread-777",
)
)):
await spawn_tool.execute(task="inspect context")
assert spawn_tool._origin_channel.get() == "discord" # type: ignore[attr-defined]
assert spawn_tool._origin_chat_id.get() == "thread-777" # type: ignore[attr-defined]
assert spawn_tool._session_key.get() == "discord:parent-456:thread:thread-777" # type: ignore[attr-defined]
call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined]
assert call["origin_channel"] == "discord"
assert call["origin_chat_id"] == "thread-777"
assert call["session_key"] == "discord:parent-456:thread:thread-777"
@pytest.mark.asyncio
@@ -1422,21 +1427,25 @@ def test_subagent_followup_skips_empty_content() -> None:
assert session.messages == []
def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_request_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None
spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined]
loop._set_tool_context(
"slack",
"C123",
with request_context(RequestContext(
channel="slack",
chat_id="C123",
message_id="msg-123",
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
session_key="slack:C123:1700.42",
)
)):
await spawn_tool.execute(task="inspect thread")
spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
assert spawn_tool._origin_message_id.get() == "msg-123"
call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined]
assert call["session_key"] == "slack:C123:1700.42"
assert call["origin_message_id"] == "msg-123"
@pytest.mark.asyncio
+4 -4
View File
@@ -24,15 +24,15 @@ class _ContextRecordingTool:
def __init__(self) -> None:
self.contexts: list[dict] = []
def set_context(self, ctx: RequestContext) -> None:
async def execute(self, **_kwargs) -> str:
ctx = current_request_context()
assert ctx is not None
self.contexts.append({
"channel": ctx.channel,
"chat_id": ctx.chat_id,
"metadata": ctx.metadata,
"session_key": ctx.session_key,
})
async def execute(self, **_kwargs) -> str:
return "created"
@@ -55,7 +55,7 @@ class _Tools:
@pytest.mark.asyncio
async def test_loop_hook_preserves_metadata_when_resetting_tool_context(tmp_path: Path) -> None:
async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None:
provider = MagicMock()
calls = {"n": 0}
+45 -37
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.long_task import (
CompleteGoalTool,
LongTaskTool,
@@ -24,15 +24,16 @@ from nanobot.session.webui_turns import WebuiTurnCoordinator
def _tools(sm: SessionManager) -> tuple[LongTaskTool, CompleteGoalTool]:
lt = LongTaskTool(sessions=sm)
cg = CompleteGoalTool(sessions=sm)
rc = RequestContext(
return lt, cg
def _request_context(chat_id: str = "c1") -> RequestContext:
return RequestContext(
channel="websocket",
chat_id="c1",
session_key="websocket:c1",
chat_id=chat_id,
session_key=f"websocket:{chat_id}",
metadata={},
)
lt.set_context(rc)
cg.set_context(rc)
return lt, cg
@pytest.mark.asyncio
@@ -40,7 +41,8 @@ async def test_long_task_records_goal_metadata(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
out = await lt.execute(goal="Do the thing", ui_summary="thing")
with request_context(_request_context()):
out = await lt.execute(goal="Do the thing", ui_summary="thing")
assert "Goal recorded" in out
sess = sm.get_or_create("websocket:c1")
@@ -56,8 +58,9 @@ async def test_long_task_rejects_second_active_goal(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
await lt.execute(goal="First")
out = await lt.execute(goal="Second")
with request_context(_request_context()):
await lt.execute(goal="First")
out = await lt.execute(goal="Second")
assert "already active" in out
@@ -66,8 +69,9 @@ async def test_complete_goal_closes_active_goal(tmp_path):
sm = SessionManager(tmp_path)
lt, cg = _tools(sm)
await lt.execute(goal="X")
out = await cg.execute(recap="Done.")
with request_context(_request_context()):
await lt.execute(goal="X")
out = await cg.execute(recap="Done.")
assert "marked complete" in out
sess = sm.get_or_create("websocket:c1")
@@ -84,19 +88,23 @@ async def test_goal_tools_keep_request_context_per_task(tmp_path):
ctx_a = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a")
ctx_b = RequestContext(channel="websocket", chat_id="b", session_key="websocket:b")
lt.set_context(ctx_a)
task_a = asyncio.create_task(lt.execute(goal="Goal A"))
lt.set_context(ctx_b)
task_b = asyncio.create_task(lt.execute(goal="Goal B"))
async def start_goal(ctx: RequestContext, goal: str) -> str:
with request_context(ctx):
return await lt.execute(goal=goal)
task_a = asyncio.create_task(start_goal(ctx_a, "Goal A"))
task_b = asyncio.create_task(start_goal(ctx_b, "Goal B"))
await asyncio.gather(task_a, task_b)
assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["objective"] == "Goal A"
assert sm.get_or_create("websocket:b").metadata[GOAL_STATE_KEY]["objective"] == "Goal B"
cg.set_context(ctx_a)
done_a = asyncio.create_task(cg.execute(recap="Done A"))
cg.set_context(ctx_b)
done_b = asyncio.create_task(cg.execute(recap="Done B"))
async def complete_goal(ctx: RequestContext, recap: str) -> str:
with request_context(ctx):
return await cg.execute(recap=recap)
done_a = asyncio.create_task(complete_goal(ctx_a, "Done A"))
done_b = asyncio.create_task(complete_goal(ctx_b, "Done B"))
await asyncio.gather(done_a, done_b)
assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["recap"] == "Done A"
@@ -104,19 +112,19 @@ async def test_goal_tools_keep_request_context_per_task(tmp_path):
@pytest.mark.asyncio
async def test_goal_tools_context_isolated_across_tool_types(tmp_path):
"""LongTaskTool and CompleteGoalTool must not share routing context."""
async def test_goal_tools_share_authoritative_request_context(tmp_path):
"""Both goal tools resolve routing from the same request snapshot."""
sm = SessionManager(tmp_path)
lt = LongTaskTool(sessions=sm)
cg = CompleteGoalTool(sessions=sm)
ctx = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a")
lt.set_context(ctx)
assert cg._request_ctx.get() is None
with request_context(ctx):
assert lt._session() is sm.get_or_create("websocket:a")
assert cg._session() is sm.get_or_create("websocket:a")
cg.set_context(ctx)
assert lt._request_ctx.get() is ctx
assert cg._request_ctx.get() is ctx
assert lt._session() is None
assert cg._session() is None
@pytest.mark.asyncio
@@ -137,9 +145,8 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
session_key="websocket:chat-99",
metadata={},
)
lt.set_context(rc)
await lt.execute(goal="Objective alpha", ui_summary="alpha")
with request_context(rc):
await lt.execute(goal="Objective alpha", ui_summary="alpha")
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
@@ -172,12 +179,11 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
session_key="websocket:chat-z",
metadata={},
)
lt.set_context(rc)
await lt.execute(goal="X")
with request_context(rc):
await lt.execute(goal="X")
bus.publish_outbound.reset_mock()
cg.set_context(rc)
await cg.execute(recap="Done.")
bus.publish_outbound.reset_mock()
await cg.execute(recap="Done.")
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
@@ -190,7 +196,8 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
sm = SessionManager(tmp_path)
_lt, cg = _tools(sm)
out = await cg.execute(recap="n/a")
with request_context(_request_context()):
out = await cg.execute(recap="n/a")
assert "No active" in out
@@ -198,7 +205,8 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
out = await lt.execute(goal="Solo", ui_summary="s")
with request_context(_request_context()):
out = await lt.execute(goal="Solo", ui_summary="s")
assert "Goal recorded" in out
+21 -8
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.self import MyTool
# ---------------------------------------------------------------------------
@@ -1121,14 +1122,26 @@ class TestLastUsageInSummary:
# ---------------------------------------------------------------------------
# set_context (audit session tracking)
# request context (audit session tracking)
# ---------------------------------------------------------------------------
class TestSetContext:
class TestRequestContext:
def test_set_context_stores_channel_and_chat_id(self):
from nanobot.agent.tools.context import RequestContext
def test_audit_reads_bound_session(self):
tool = _make_tool()
tool.set_context(RequestContext(channel="feishu", chat_id="oc_abc123"))
assert tool._channel == "feishu"
assert tool._chat_id == "oc_abc123"
ctx = RequestContext(
channel="feishu",
chat_id="oc_abc123",
session_key="feishu:oc_abc123",
)
with patch("nanobot.agent.tools.self.logger.info") as info:
with request_context(ctx):
tool._audit("modify", "temperature = 0.2")
info.assert_called_once_with(
"self.{} | {} | session:{}",
"modify",
"temperature = 0.2",
"feishu:oc_abc123",
)
+9 -10
View File
@@ -159,19 +159,18 @@ async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
mgr.runner.run = AsyncMock(side_effect=fake_run)
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, request_context
tool = SpawnTool(mgr)
tool.set_context(RequestContext(channel="test", chat_id="c1", session_key="test:c1"))
with request_context(RequestContext(channel="test", chat_id="c1", session_key="test:c1")):
# First spawn succeeds
result = await tool.execute(task="first task")
assert "started" in result
# First spawn succeeds
result = await tool.execute(task="first task")
assert "started" in result
# Second spawn should be rejected (default limit is 1)
result = await tool.execute(task="second task")
assert "Cannot spawn subagent" in result
assert "concurrency limit reached" in result
# Second spawn should be rejected (default limit is 1)
result = await tool.execute(task="second task")
assert "Cannot spawn subagent" in result
assert "concurrency limit reached" in result
# Release the first subagent
release.set()
+26 -25
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
import pytest
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
@@ -321,11 +321,10 @@ def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context(
with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
):
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
@@ -334,11 +333,12 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context(
with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
):
result = tool._add_job(
None, "Morning reminder", None, None, None, "2026-03-25T08:00:00"
)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
@@ -348,11 +348,10 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
def test_add_job_binds_current_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(
with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "Morning standup", 60, None, None, None)
):
result = tool._add_job(None, "Morning standup", 60, None, None, None)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
@@ -366,9 +365,8 @@ def test_add_job_binds_current_session_key(tmp_path) -> None:
def test_add_job_requires_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
result = tool._add_job(None, "Background refresh", 60, None, None, None)
with request_context(RequestContext(channel="telegram", chat_id="chat-1")):
result = tool._add_job(None, "Background refresh", 60, None, None, None)
assert result == "Error: scheduled cron jobs must be created from a chat session"
assert tool._cron.list_jobs() == []
@@ -403,11 +401,10 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path)
tool.set_context(
with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
)
result = tool._add_job(None, "", 60, None, None, None)
):
result = tool._add_job(None, "", 60, None, None, None)
assert "action='add' requires a non-empty 'message'" in result
assert "Retry including message=" in result
@@ -417,11 +414,15 @@ def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_pa
"""CronTool stores owner/session identity separately from origin delivery context."""
tool = _make_tool(tmp_path)
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context(RequestContext(
channel="slack", chat_id="C99", metadata=meta, session_key="slack:C99:111.222"
))
result = tool._add_job("test", "say hi", 60, None, None, None)
with request_context(
RequestContext(
channel="slack",
chat_id="C99",
metadata=meta,
session_key="slack:C99:111.222",
)
):
result = tool._add_job("test", "say hi", 60, None, None, None)
assert "Created job" in result
jobs = tool._cron.list_jobs()
+8 -6
View File
@@ -9,9 +9,11 @@ and tightens the runtime error for ``add`` without ``message``.
from __future__ import annotations
from collections.abc import Iterator
import pytest
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.registry import ToolRegistry
@@ -39,14 +41,14 @@ class _SvcStub:
@pytest.fixture
def registry() -> ToolRegistry:
def registry() -> Iterator[ToolRegistry]:
tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context(
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
)
reg = ToolRegistry()
reg.register(tool)
return reg
with request_context(
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
):
yield reg
class TestSchemaContract:
+56 -67
View File
@@ -4,8 +4,7 @@ import asyncio
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
@@ -26,16 +25,16 @@ async def test_message_tool_keeps_task_local_context() -> None:
tool = MessageTool(send_callback=send_callback)
async def task_one() -> str:
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a"))
entered.set()
await release.wait()
return await tool.execute(content="one")
with request_context(RequestContext(channel="feishu", chat_id="chat-a")):
entered.set()
await release.wait()
return await tool.execute(content="one")
async def task_two() -> str:
await entered.wait()
tool.set_context(RequestContext(channel="email", chat_id="chat-b"))
release.set()
return await tool.execute(content="two")
with request_context(RequestContext(channel="email", chat_id="chat-b")):
release.set()
return await tool.execute(content="two")
result_one, result_two = await asyncio.gather(task_one(), task_two())
@@ -75,16 +74,16 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
tool = SpawnTool(_Manager())
async def task_one() -> str:
tool.set_context(RequestContext(channel="whatsapp", chat_id="chat-a"))
entered.set()
await release.wait()
return await tool.execute(task="one")
with request_context(RequestContext(channel="whatsapp", chat_id="chat-a")):
entered.set()
await release.wait()
return await tool.execute(task="one")
async def task_two() -> str:
await entered.wait()
tool.set_context(RequestContext(channel="telegram", chat_id="chat-b"))
release.set()
return await tool.execute(task="two")
with request_context(RequestContext(channel="telegram", chat_id="chat-b")):
release.set()
return await tool.execute(task="two")
result_one, result_two = await asyncio.gather(task_one(), task_two())
@@ -101,20 +100,20 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
release = asyncio.Event()
async def task_one() -> str:
tool.set_context(
with request_context(
RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a")
)
entered.set()
await release.wait()
return await tool.execute(action="add", message="first", every_seconds=60)
):
entered.set()
await release.wait()
return await tool.execute(action="add", message="first", every_seconds=60)
async def task_two() -> str:
await entered.wait()
tool.set_context(
with request_context(
RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b")
)
release.set()
return await tool.execute(action="add", message="second", every_seconds=60)
):
release.set()
return await tool.execute(action="add", message="second", every_seconds=60)
result_one, result_two = await asyncio.gather(task_one(), task_two())
@@ -133,24 +132,25 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
@pytest.mark.asyncio
async def test_message_tool_basic_set_context_and_execute() -> None:
"""Single task: set_context then execute should route correctly."""
async def test_message_tool_basic_request_context_and_execute() -> None:
"""A bound request context should route a single execution correctly."""
seen: list[tuple[str, str, str]] = []
async def send_callback(msg):
seen.append((msg.channel, msg.chat_id, msg.content))
tool = MessageTool(send_callback=send_callback)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-123", message_id="msg-456"))
result = await tool.execute(content="hello")
with request_context(
RequestContext(channel="telegram", chat_id="chat-123", message_id="msg-456")
):
result = await tool.execute(content="hello")
assert result == "Message sent to telegram:chat-123"
assert seen == [("telegram", "chat-123", "hello")]
@pytest.mark.asyncio
async def test_message_tool_default_values_without_set_context() -> None:
"""Without set_context, constructor defaults should be used."""
async def test_message_tool_default_values_without_request_context() -> None:
"""Without a request context, constructor defaults should be used."""
seen: list[tuple[str, str, str]] = []
async def send_callback(msg):
@@ -168,8 +168,8 @@ async def test_message_tool_default_values_without_set_context() -> None:
@pytest.mark.asyncio
async def test_spawn_tool_basic_set_context_and_execute() -> None:
"""Single task: set_context then execute should pass correct origin."""
async def test_spawn_tool_basic_request_context_and_execute() -> None:
"""A bound request context should provide the correct origin."""
seen: list[tuple[str, str, str]] = []
class _Manager:
@@ -194,16 +194,15 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
return f"ok: {task}"
tool = SpawnTool(_Manager())
tool.set_context(RequestContext(channel="feishu", chat_id="chat-abc"))
result = await tool.execute(task="do something")
with request_context(RequestContext(channel="feishu", chat_id="chat-abc")):
result = await tool.execute(task="do something")
assert result == "ok: do something"
assert seen == [("feishu", "chat-abc", "feishu:chat-abc")]
@pytest.mark.asyncio
async def test_spawn_tool_default_values_without_set_context() -> None:
"""Without set_context, default cli:direct should be used."""
async def test_spawn_tool_default_values_without_request_context() -> None:
"""Without a request context, default cli:direct should be used."""
seen: list[tuple[str, str, str]] = []
class _Manager:
@@ -234,14 +233,13 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
@pytest.mark.asyncio
async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
"""Single task: set_context then add job should use correct target."""
async def test_cron_tool_basic_request_context_and_execute(tmp_path) -> None:
"""A bound request context should provide the correct cron owner."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context(
with request_context(
RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789")
)
result = await tool.execute(action="add", message="standup", every_seconds=300)
):
result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
@@ -256,23 +254,15 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
"""WebUI-created cron jobs stay attached to the creating chat."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
class _Tools:
tool_names = ["cron"]
def get(self, name: str):
return tool if name == "cron" else None
loop = object.__new__(AgentLoop)
loop._unified_session = True
loop.tools = _Tools()
loop._set_tool_context(
"websocket",
"chat-123",
metadata={"webui": True},
session_key=UNIFIED_SESSION_KEY,
)
result = await tool.execute(action="add", message="standup", every_seconds=300)
with request_context(
RequestContext(
channel="websocket",
chat_id="chat-123",
metadata={"webui": True},
session_key=UNIFIED_SESSION_KEY,
)
):
result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
@@ -287,16 +277,15 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
"""Channel-provided thread session keys should remain the cron owner."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context(
with request_context(
RequestContext(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "1700.42"}},
session_key="slack:C123:1700.42",
)
)
result = await tool.execute(action="add", message="check thread", every_seconds=300)
):
result = await tool.execute(action="add", message="check thread", every_seconds=300)
assert result.startswith("Created job")
jobs = tool._cron.list_jobs()
@@ -309,7 +298,7 @@ async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
@pytest.mark.asyncio
async def test_cron_tool_no_context_returns_error(tmp_path) -> None:
"""Without set_context, add should fail with a clear error."""
"""Without a request context, add should fail with a clear error."""
tool = CronTool(CronService(tmp_path / "jobs.json"))
result = await tool.execute(action="add", message="test", every_seconds=60)
+61 -68
View File
@@ -2,6 +2,7 @@ import os
import pytest
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.message import MessageTool
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
@@ -108,11 +109,9 @@ async def test_message_tool_inherits_metadata_for_same_target() -> None:
tool = MessageTool(send_callback=_send)
slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata=slack_meta))
await tool.execute(content="thread reply")
with request_context(RequestContext(channel="slack", chat_id="C123", metadata=slack_meta)):
await tool.execute(content="thread reply")
assert sent[0].metadata == slack_meta
@@ -125,18 +124,23 @@ async def test_message_tool_clears_metadata_when_context_has_none() -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
rich_context = RequestContext(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
)
with request_context(rich_context):
await tool.execute(content="thread reply")
sent.clear()
tool.set_context(
with request_context(
RequestContext(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
metadata={},
),
)
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata={}))
await tool.execute(content="plain reply")
):
await tool.execute(content="plain reply")
assert sent[0].metadata == {}
@@ -149,17 +153,14 @@ async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(
with request_context(
RequestContext(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
),
)
await tool.execute(content="channel reply", channel="slack", chat_id="C999")
):
await tool.execute(content="channel reply", channel="slack", chat_id="C999")
assert sent[0].metadata == {}
@@ -337,15 +338,17 @@ async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(content="see file", channel="websocket", chat_id="chat-1", media=[str(f)])
assert tool.turn_delivered_media_paths() == [str(f.resolve())]
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
tool.start_turn()
await tool.execute(
content="see file",
channel="websocket",
chat_id="chat-1",
media=[str(f)],
)
assert tool.turn_delivered_media_paths() == [str(f.resolve())]
@pytest.mark.asyncio
@@ -354,15 +357,13 @@ async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None:
pass
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(content="see file", media=[str(f)])
tool.start_turn()
assert tool.turn_delivered_media_paths() == []
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
tool.start_turn()
await tool.execute(content="see file", media=[str(f)])
tool.start_turn()
assert tool.turn_delivered_media_paths() == []
@pytest.mark.asyncio
@@ -371,18 +372,16 @@ async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) ->
pass
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(
content="see file",
channel="telegram",
chat_id="tg-other",
media=[str(f)],
)
assert tool.turn_delivered_media_paths() == []
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
await tool.execute(
content="see file",
channel="telegram",
chat_id="tg-other",
media=[str(f)],
)
assert tool.turn_delivered_media_paths() == []
@pytest.mark.asyncio
@@ -393,18 +392,16 @@ async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="see file",
channel="websocket",
chat_id="anon-deadbeefcafe",
media=[str(f)],
)
with request_context(RequestContext(channel="websocket", chat_id=conv, metadata={})):
result = await tool.execute(
content="see file",
channel="websocket",
chat_id="anon-deadbeefcafe",
media=[str(f)],
)
assert result.startswith("Error: chat_id does not match")
assert sent == []
@@ -417,18 +414,16 @@ async def test_message_tool_allows_ws_explicit_when_matches_context(tmp_path) ->
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="see file",
channel="websocket",
chat_id=conv,
media=[str(f)],
)
with request_context(RequestContext(channel="websocket", chat_id=conv, metadata={})):
result = await tool.execute(
content="see file",
channel="websocket",
chat_id=conv,
media=[str(f)],
)
assert result.startswith("Message sent")
assert sent[0].chat_id == conv
@@ -442,18 +437,16 @@ async def test_message_tool_cli_context_may_target_other_ws_chat(tmp_path) -> No
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
target = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="cli", chat_id="direct", metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="ping",
channel="websocket",
chat_id=target,
media=[str(f)],
)
with request_context(RequestContext(channel="cli", chat_id="direct", metadata={})):
result = await tool.execute(
content="ping",
channel="websocket",
chat_id=target,
media=[str(f)],
)
assert result.startswith("Message sent")
assert sent[0].channel == "websocket"
assert sent[0].chat_id == target
+6 -5
View File
@@ -156,11 +156,12 @@ class TestMessageToolTurnTracking:
def test_sent_in_turn_tracks_same_target(self) -> None:
tool = MessageTool()
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="feishu", chat_id="chat1"))
assert not tool._sent_in_turn
tool._sent_in_turn = True
assert tool._sent_in_turn
from nanobot.agent.tools.context import RequestContext, request_context
with request_context(RequestContext(channel="feishu", chat_id="chat1")):
assert not tool._sent_in_turn
tool._sent_in_turn = True
assert tool._sent_in_turn
def test_start_turn_resets(self) -> None:
tool = MessageTool()