mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
feat(webui): add lightweight session messaging via mentions
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import ToolResult
|
||||
from nanobot.agent.tools.context import RequestContext, ToolContext, request_context
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.session_messages import ListSessionsTool, SendSessionMessageTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import SessionHandleDirectory
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def test_send_session_message_requires_an_explicit_boolean_reply_contract(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
parameters = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=MessageBus(),
|
||||
).parameters
|
||||
|
||||
assert parameters["required"] == ["to", "content", "expect_reply"]
|
||||
assert parameters["properties"]["expect_reply"]["type"] == "boolean"
|
||||
timeout = parameters["properties"]["reply_timeout_seconds"]
|
||||
assert (timeout["type"], timeout["minimum"], timeout["maximum"]) == (
|
||||
"integer",
|
||||
5,
|
||||
60,
|
||||
)
|
||||
|
||||
|
||||
def test_session_message_rate_limit_config_defaults_to_six_per_minute() -> None:
|
||||
assert ToolsConfig().max_session_messages_per_minute == 6
|
||||
configured = ToolsConfig.model_validate({"maxSessionMessagesPerMinute": 9})
|
||||
assert configured.max_session_messages_per_minute == 9
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ToolsConfig(max_session_messages_per_minute=0)
|
||||
|
||||
|
||||
def _session_message_metadata(*, expect_reply: bool = True) -> dict[str, object]:
|
||||
return {
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": expect_reply,
|
||||
"source": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
"handle_id": "handle_reviewer",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "author",
|
||||
"session_key": "websocket:author",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _reply_timeout_metadata() -> dict[str, object]:
|
||||
return {
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {
|
||||
"created_at_ms": 1,
|
||||
"message_id": "handle-message-1",
|
||||
"expect_reply": True,
|
||||
"timeout_seconds": 60,
|
||||
"source": {
|
||||
"name": "author",
|
||||
"session_key": "websocket:author",
|
||||
"handle_id": "handle_author",
|
||||
"color_slot": 2,
|
||||
},
|
||||
"target": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _save_session(
|
||||
sessions: SessionManager,
|
||||
key: str,
|
||||
*,
|
||||
workspace: Path,
|
||||
title: str,
|
||||
webui: bool,
|
||||
) -> None:
|
||||
session = sessions.get_or_create(key)
|
||||
session.metadata.update({
|
||||
"title": title,
|
||||
"webui": webui,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(workspace.resolve()),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
})
|
||||
sessions.save(session, fsync=True)
|
||||
|
||||
|
||||
def _empty_send_tool(tmp_path: Path) -> SendSessionMessageTool:
|
||||
return SendSessionMessageTool(
|
||||
sessions=SessionManager(tmp_path / "state"),
|
||||
bus=MessageBus(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_uses_configured_per_minute_limit(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
for key in ("websocket:lead", "websocket:reviewer"):
|
||||
_save_session(sessions, key, workspace=workspace, title=key, webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many(["websocket:lead", "websocket:reviewer"])
|
||||
reviewer = handles["websocket:reviewer"]
|
||||
tool = SendSessionMessageTool.create(ToolContext(
|
||||
config=ToolsConfig(max_session_messages_per_minute=1),
|
||||
workspace=str(workspace),
|
||||
bus=MessageBus(),
|
||||
sessions=sessions,
|
||||
))
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="lead",
|
||||
session_key="websocket:lead",
|
||||
workspace=workspace,
|
||||
)):
|
||||
first = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="First",
|
||||
expect_reply=False,
|
||||
)
|
||||
second = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="Second",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert first == f"Sent to @{reviewer.name}."
|
||||
assert isinstance(second, ToolResult)
|
||||
assert second.is_error
|
||||
assert "1 per minute" in str(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_queues_user_input_for_target_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
for key in ("websocket:lead", "websocket:reviewer"):
|
||||
_save_session(sessions, key, workspace=workspace, title=key, webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
reviewer = directory.ensure_many(["websocket:lead", "websocket:reviewer"])[
|
||||
"websocket:reviewer"
|
||||
]
|
||||
bus = MessageBus()
|
||||
tool = SendSessionMessageTool(sessions=sessions, bus=bus, directory=directory)
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="lead",
|
||||
session_key="websocket:lead",
|
||||
turn_id="turn-1",
|
||||
workspace=workspace,
|
||||
metadata={"safe": "context"},
|
||||
)):
|
||||
result = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="Review this",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert result == f"Sent to @{reviewer.name}."
|
||||
inbound = bus.inbound.get_nowait()
|
||||
assert inbound.session_key_override == "websocket:reviewer"
|
||||
assert inbound.content == "Review this"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_requires_a_session_context(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
|
||||
result = await tool.execute(
|
||||
to="@reviewer",
|
||||
content="Review this",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.is_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_guides_a_requested_reply(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="author",
|
||||
metadata=_session_message_metadata(),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "Message from @reviewer. Reply with send_session_message."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_omits_unrequested_reply_guidance(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="author",
|
||||
metadata=_session_message_metadata(expect_reply=False),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "Message from @reviewer."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_guides_a_timed_out_reply(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="system",
|
||||
chat_id="websocket:author",
|
||||
metadata=_reply_timeout_metadata(),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "No reply from @reviewer after 60s."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_runtime_context_identifies_self_and_verified_mentions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "websocket:source"
|
||||
target_key = "websocket:handle"
|
||||
_save_session(sessions, source_key, workspace=project, title="Source", webui=True)
|
||||
_save_session(sessions, target_key, workspace=project, title="Session", webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many([source_key, target_key])
|
||||
source = handles[source_key]
|
||||
handle = handles[target_key]
|
||||
provider = ListSessionsTool(sessions).runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
metadata={
|
||||
"session_handles": [{
|
||||
**handle.public_payload(),
|
||||
"session_key": handle.session_key,
|
||||
}],
|
||||
},
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_handle"
|
||||
assert block.content == (
|
||||
f"Your handle: @{source.name}.\n"
|
||||
f"Mentioned sessions: @{handle.name}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_returns_all_session_handles_across_workspaces(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
other_project = tmp_path / "other"
|
||||
project.mkdir()
|
||||
other_project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "websocket:source"
|
||||
target_key = "websocket:handle"
|
||||
external_key = "telegram:external"
|
||||
other_key = "websocket:other"
|
||||
_save_session(
|
||||
sessions,
|
||||
source_key,
|
||||
workspace=project,
|
||||
title="The source title must stay private",
|
||||
webui=True,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
target_key,
|
||||
workspace=project,
|
||||
title="The handle title must stay private",
|
||||
webui=True,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
external_key,
|
||||
workspace=project,
|
||||
title="External conversation",
|
||||
webui=False,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
other_key,
|
||||
workspace=other_project,
|
||||
title="Other workspace",
|
||||
webui=True,
|
||||
)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
|
||||
tool = ListSessionsTool(sessions)
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
)):
|
||||
result = json.loads(await tool.execute())
|
||||
|
||||
handles = directory.ensure_many([target_key, source_key, external_key, other_key])
|
||||
handle = handles[target_key]
|
||||
source = handles[source_key]
|
||||
external = handles[external_key]
|
||||
other = handles[other_key]
|
||||
assert result == sorted([
|
||||
f"@{handle.name}",
|
||||
f"@{external.name}",
|
||||
f"@{other.name}",
|
||||
])
|
||||
assert f"@{source.name}" not in result
|
||||
encoded = json.dumps(result)
|
||||
assert "title" not in encoded
|
||||
assert "session_key" not in encoded
|
||||
assert str(project) not in encoded
|
||||
assert str(other_project) not in encoded
|
||||
assert tool.read_only is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_requires_trusted_turn_context(tmp_path: Path) -> None:
|
||||
tool = ListSessionsTool(SessionManager(tmp_path / "state"))
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.startswith("Error:")
|
||||
assert result.is_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_supports_non_webui_source_and_allocates_all_handles(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "telegram:source"
|
||||
target_key = "websocket:handle"
|
||||
_save_session(
|
||||
sessions,
|
||||
source_key,
|
||||
workspace=project,
|
||||
title="External source",
|
||||
webui=False,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
target_key,
|
||||
workspace=project,
|
||||
title="WebUI handle",
|
||||
webui=True,
|
||||
)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
tool = ListSessionsTool(sessions)
|
||||
|
||||
request = RequestContext(
|
||||
channel="telegram",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
)
|
||||
with request_context(request):
|
||||
result = await tool.execute()
|
||||
block = await tool.runtime_context_provider()(request)
|
||||
|
||||
handles = directory.ensure_many([source_key, target_key])
|
||||
assert result == json.dumps([f"@{handles[target_key].name}"])
|
||||
assert block is not None
|
||||
assert block.content == f"Your handle: @{handles[source_key].name}."
|
||||
assert directory.store_path.exists()
|
||||
|
||||
|
||||
def test_list_sessions_is_auto_discovered() -> None:
|
||||
discovered = ToolLoader().discover()
|
||||
assert ListSessionsTool in discovered
|
||||
assert SendSessionMessageTool in discovered
|
||||
assert not any(tool.__name__ == "ReplySessionTool" for tool in discovered)
|
||||
Reference in New Issue
Block a user