mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
refactor: simplify cross-session messaging
This commit is contained in:
@@ -23,7 +23,6 @@ from nanobot.bus.outbound_events import (
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_MESSAGE_META, detach_runtime_context
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
@@ -854,16 +853,11 @@ class TestToolEventProgress:
|
||||
assert len(requests) == 2
|
||||
assert requests[0][-1]["role"] == "user"
|
||||
assert requests[0][-1]["content"].endswith("Background research completed")
|
||||
follow_up = next(
|
||||
message
|
||||
assert any(
|
||||
message.get("role") == "user"
|
||||
and message.get("content") == "Can you include the key detail?"
|
||||
for message in requests[1]
|
||||
if message.get("role") == "user"
|
||||
and str(message.get("content", "")).startswith("Can you include the key detail?")
|
||||
)
|
||||
marker = follow_up["_meta"][RUNTIME_CONTEXT_MESSAGE_META]
|
||||
detached = detach_runtime_context(follow_up["content"], marker)
|
||||
assert detached is not None
|
||||
assert detached[0] == "Can you include the key detail?"
|
||||
assert len(request_contexts) == 1
|
||||
request_ctx = request_contexts[0]
|
||||
assert request_ctx is not None
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"""Tests for SessionManager.delete_session and read_session_file."""
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
|
||||
from nanobot.session.manager import (
|
||||
SESSION_MODEL_PRESET_METADATA_KEY,
|
||||
Session,
|
||||
SessionManager,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
def _seed(workspace: Path, key: str = "telegram:abc") -> SessionManager:
|
||||
@@ -34,85 +29,6 @@ def test_delete_session_removes_file_and_invalidates_cache(tmp_path: Path) -> No
|
||||
assert fresh.messages == []
|
||||
|
||||
|
||||
def test_deleted_session_object_cannot_recreate_file(tmp_path: Path) -> None:
|
||||
sm = _seed(tmp_path, "websocket:abc")
|
||||
stale = sm.get_or_create("websocket:abc")
|
||||
|
||||
assert sm.delete_session(stale.key) is True
|
||||
stale.add_message("assistant", "late result")
|
||||
sm.save(stale)
|
||||
|
||||
assert sm.read_session_metadata(stale.key) is None
|
||||
|
||||
|
||||
def test_delete_is_atomic_with_existing_session_load(tmp_path: Path) -> None:
|
||||
sm = _seed(tmp_path, "websocket:abc")
|
||||
sm.invalidate("websocket:abc")
|
||||
loaded = Event()
|
||||
release = Event()
|
||||
deleted = Event()
|
||||
original_load = sm._load
|
||||
|
||||
def paused_load(key: str) -> Session | None:
|
||||
session = original_load(key)
|
||||
loaded.set()
|
||||
assert release.wait(timeout=2)
|
||||
return session
|
||||
|
||||
sm._load = paused_load # type: ignore[method-assign]
|
||||
load_thread = Thread(target=sm.get_existing, args=("websocket:abc",))
|
||||
delete_thread = Thread(
|
||||
target=lambda: (sm.delete_session("websocket:abc"), deleted.set()),
|
||||
)
|
||||
load_thread.start()
|
||||
assert loaded.wait(timeout=2)
|
||||
delete_thread.start()
|
||||
assert not deleted.wait(timeout=0.05)
|
||||
release.set()
|
||||
load_thread.join(timeout=2)
|
||||
delete_thread.join(timeout=2)
|
||||
|
||||
assert not load_thread.is_alive()
|
||||
assert not delete_thread.is_alive()
|
||||
assert sm.read_session_metadata("websocket:abc") is None
|
||||
assert sm.get_cached("websocket:abc") is None
|
||||
|
||||
|
||||
def test_delete_is_atomic_with_model_preset_rename(tmp_path: Path) -> None:
|
||||
sm = _seed(tmp_path, "websocket:abc")
|
||||
session = sm.get_or_create("websocket:abc")
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "old"
|
||||
sm.save(session)
|
||||
sm.invalidate(session.key)
|
||||
loaded = Event()
|
||||
release = Event()
|
||||
deleted = Event()
|
||||
original_load = sm._load
|
||||
|
||||
def paused_load(key: str) -> Session | None:
|
||||
candidate = original_load(key)
|
||||
loaded.set()
|
||||
assert release.wait(timeout=2)
|
||||
return candidate
|
||||
|
||||
sm._load = paused_load # type: ignore[method-assign]
|
||||
rename_thread = Thread(target=sm.rename_model_preset, args=("old", "new"))
|
||||
delete_thread = Thread(
|
||||
target=lambda: (sm.delete_session("websocket:abc"), deleted.set()),
|
||||
)
|
||||
rename_thread.start()
|
||||
assert loaded.wait(timeout=2)
|
||||
delete_thread.start()
|
||||
assert not deleted.wait(timeout=0.05)
|
||||
release.set()
|
||||
rename_thread.join(timeout=2)
|
||||
delete_thread.join(timeout=2)
|
||||
|
||||
assert not rename_thread.is_alive()
|
||||
assert not delete_thread.is_alive()
|
||||
assert sm.read_session_metadata("websocket:abc") is None
|
||||
|
||||
|
||||
def test_delete_session_returns_false_when_missing(tmp_path: Path) -> None:
|
||||
sm = SessionManager(tmp_path)
|
||||
assert sm.delete_session("nope:none") is False
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
"""Session-authored user input behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -10,33 +6,12 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_HISTORY_META, public_history_message
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.session_handles import SessionHandleDirectory
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
)
|
||||
from nanobot.session.webui_turns import (
|
||||
project_session_message_input,
|
||||
websocket_turn_wall_started_at,
|
||||
)
|
||||
from nanobot.webui.transcript import read_transcript_lines
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_webui_transcript(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.transcript.get_webui_dir",
|
||||
lambda: tmp_path / "webui",
|
||||
)
|
||||
from nanobot.runtime_context import public_history_message
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_messages import SESSION_MESSAGE_METADATA_KEY
|
||||
|
||||
|
||||
def _loop(tmp_path: Path) -> AgentLoop:
|
||||
@@ -46,600 +21,81 @@ def _loop(tmp_path: Path) -> AgentLoop:
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="Reviewed", tool_calls=[], usage={})
|
||||
)
|
||||
loop = AgentLoop(
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
for key in ("websocket:source", "websocket:target"):
|
||||
session = loop.sessions.get_or_create(key)
|
||||
session.metadata["webui"] = True
|
||||
loop.sessions.save(session)
|
||||
return loop
|
||||
|
||||
|
||||
def _session_message(
|
||||
loop: AgentLoop,
|
||||
content: str = "Review the change",
|
||||
*,
|
||||
message_id: str = "handle-message-1",
|
||||
expect_reply: bool = True,
|
||||
source_key: str = "websocket:source",
|
||||
target_key: str = "websocket:target",
|
||||
) -> InboundMessage:
|
||||
directory = SessionHandleDirectory(loop.sessions)
|
||||
handles = directory.ensure_many([source_key, target_key])
|
||||
source = handles[source_key]
|
||||
target = handles[target_key]
|
||||
is_webui = target_key.startswith("websocket:")
|
||||
return InboundMessage(
|
||||
channel="websocket" if is_webui else "system",
|
||||
sender_id="session",
|
||||
chat_id=target_key.split(":", 1)[1] if is_webui else target_key,
|
||||
content=content,
|
||||
metadata={
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": message_id,
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": expect_reply,
|
||||
"source": {
|
||||
"name": source.name,
|
||||
"session_key": source.session_key,
|
||||
"handle_id": source.id,
|
||||
"color_slot": source.color_slot,
|
||||
},
|
||||
"target": {
|
||||
"name": target.name,
|
||||
"session_key": target.session_key,
|
||||
},
|
||||
}
|
||||
},
|
||||
session_key_override=target_key,
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _session_reply_timeout_message(loop: AgentLoop, *, timeout_seconds: int = 60) -> InboundMessage:
|
||||
directory = SessionHandleDirectory(loop.sessions)
|
||||
handles = directory.ensure_many(["websocket:source", "websocket:target"])
|
||||
waiter = handles["websocket:source"]
|
||||
handle = handles["websocket:target"]
|
||||
def _message(content: str = "Please review") -> InboundMessage:
|
||||
envelope = {
|
||||
"message_id": "message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"source_session_key": "websocket:source",
|
||||
"target_session_key": "telegram:target",
|
||||
}
|
||||
return InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session_timeout",
|
||||
chat_id="websocket:source",
|
||||
content="",
|
||||
metadata={
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"source": {
|
||||
"name": waiter.name,
|
||||
"session_key": waiter.session_key,
|
||||
"handle_id": waiter.id,
|
||||
"color_slot": waiter.color_slot,
|
||||
},
|
||||
"target": {
|
||||
"name": handle.name,
|
||||
"session_key": handle.session_key,
|
||||
},
|
||||
},
|
||||
},
|
||||
session_key_override="websocket:source",
|
||||
require_existing_session=True,
|
||||
sender_id="session",
|
||||
chat_id="telegram:target",
|
||||
content=content,
|
||||
metadata={SESSION_MESSAGE_METADATA_KEY: envelope},
|
||||
session_key_override="telegram:target",
|
||||
input_role="user",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_keeps_reply_guidance_private_runtime_context(
|
||||
async def test_session_message_runs_as_user_input_and_replies_on_target_route(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path / "state")
|
||||
loop = _loop(tmp_path)
|
||||
loop.sessions.invalidate("websocket:target")
|
||||
loop.sessions.save(loop.sessions.get_or_create("telegram:target"))
|
||||
msg = _message()
|
||||
|
||||
response = await loop._process_message(_session_message(loop))
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert (response.channel, response.chat_id) == ("websocket", "target")
|
||||
directory = SessionHandleDirectory(loop.sessions)
|
||||
handles = directory.ensure_many(["websocket:source", "websocket:target"])
|
||||
source_name = handles["websocket:source"].name
|
||||
target_name = handles["websocket:target"].name
|
||||
expected_provider_input = (
|
||||
"Review the change\n\n"
|
||||
f"Your handle: @{target_name}.\n\n"
|
||||
f"Message from @{source_name}. Reply with send_session_message."
|
||||
assert (response.channel, response.chat_id, response.content) == (
|
||||
"telegram",
|
||||
"target",
|
||||
"Reviewed",
|
||||
)
|
||||
session = loop.sessions.get_or_create("websocket:target")
|
||||
session_input = next(message for message in session.messages if message.get("role") == "user")
|
||||
assert session_input["content"] == expected_provider_input
|
||||
assert public_history_message(session_input)["content"] == "Review the change"
|
||||
assert SESSION_MESSAGE_METADATA_KEY in session_input
|
||||
|
||||
provider_messages = loop.provider.chat_with_retry.await_args.kwargs["messages"]
|
||||
provider_input = next(
|
||||
message for message in reversed(provider_messages) if message.get("role") == "user"
|
||||
row for row in reversed(provider_messages) if row.get("role") == "user"
|
||||
)
|
||||
assert provider_input["content"] == expected_provider_input
|
||||
source_name = session_handle_for_key("websocket:source").name
|
||||
assert provider_input["content"].startswith("Please review")
|
||||
assert f"Message from @{source_name}." in provider_input["content"]
|
||||
assert "Reply with send_session_message." in provider_input["content"]
|
||||
|
||||
loop.sessions.invalidate("websocket:target")
|
||||
replay = loop.sessions.get_or_create("websocket:target").get_history()
|
||||
replay_input = next(message for message in replay if message.get("role") == "user")
|
||||
assert replay_input["content"] == provider_input["content"]
|
||||
stored = loop.sessions.get_or_create("telegram:target").messages
|
||||
user_row = next(row for row in stored if row.get("role") == "user")
|
||||
assert public_history_message(user_row)["content"] == "Please review"
|
||||
assert SESSION_MESSAGE_METADATA_KEY not in user_row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_runs_as_user_turn_for_non_websocket_session(
|
||||
async def test_session_message_text_is_not_dispatched_as_a_slash_command(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path / "state")
|
||||
loop = _loop(tmp_path)
|
||||
target_key = "telegram:target"
|
||||
loop.sessions.save(loop.sessions.get_or_create(target_key))
|
||||
loop.sessions.invalidate(target_key)
|
||||
loop.sessions.save(loop.sessions.get_or_create("telegram:target"))
|
||||
task = asyncio.create_task(loop.run())
|
||||
try:
|
||||
await loop.bus.publish_inbound(_message("/stop"))
|
||||
response = await asyncio.wait_for(loop.bus.consume_outbound(), timeout=2)
|
||||
|
||||
message = _session_message(loop, target_key=target_key)
|
||||
response = await loop._process_message(message)
|
||||
|
||||
assert message.channel == "system"
|
||||
assert response is not None
|
||||
assert (response.channel, response.chat_id) == ("telegram", "target")
|
||||
handles = SessionHandleDirectory(loop.sessions).ensure_many([
|
||||
"websocket:source",
|
||||
target_key,
|
||||
])
|
||||
expected_provider_input = (
|
||||
"Review the change\n\n"
|
||||
f"Your handle: @{handles[target_key].name}.\n\n"
|
||||
f"Message from @{handles['websocket:source'].name}. Reply with send_session_message."
|
||||
)
|
||||
session = loop.sessions.get_or_create(target_key)
|
||||
session_input = next(item for item in session.messages if item.get("role") == "user")
|
||||
assert session_input["content"] == expected_provider_input
|
||||
assert public_history_message(session_input)["content"] == "Review the change"
|
||||
assert SESSION_MESSAGE_METADATA_KEY in session_input
|
||||
assert read_transcript_lines(target_key) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_publishes_running_state_before_projection(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
|
||||
async def project(
|
||||
bus: MessageBus,
|
||||
message: InboundMessage,
|
||||
session_key: str,
|
||||
) -> None:
|
||||
assert websocket_turn_wall_started_at("target") is not None
|
||||
await project_session_message_input(bus, message, session_key)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.loop.project_session_message_input", project)
|
||||
|
||||
await loop._dispatch(_session_message(loop))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mid_turn_session_input_keeps_reply_guidance_and_provenance(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="First", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Second", tool_calls=[], usage={}),
|
||||
])
|
||||
session = loop.sessions.get_or_create("websocket:target")
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending.put(_session_message(loop))
|
||||
request = RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
turn_id="active-turn",
|
||||
workspace=tmp_path,
|
||||
)
|
||||
|
||||
_, _, all_messages, _, had_injections = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "Initial request"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
pending_queue=pending,
|
||||
request_context=request,
|
||||
)
|
||||
|
||||
source = SessionHandleDirectory(loop.sessions).ensure_many(["websocket:source"])[
|
||||
"websocket:source"
|
||||
]
|
||||
injected = [item for item in all_messages if item.get("role") == "user"][-1]
|
||||
assert had_injections is True
|
||||
assert f"Message from @{source.name}." in str(injected["content"])
|
||||
assert injected[SESSION_MESSAGE_METADATA_KEY]["message_id"] == "handle-message-1"
|
||||
loop._save_turn(session, all_messages, skip=1)
|
||||
persisted = [item for item in session.messages if item.get("role") == "user"][-1]
|
||||
assert persisted[SESSION_MESSAGE_METADATA_KEY]["message_id"] == "handle-message-1"
|
||||
assert public_history_message(persisted)["content"] == "Review the change"
|
||||
assert SESSION_MESSAGE_METADATA_KEY not in request.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_does_not_replace_active_request_metadata(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="First", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Second", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Third", tool_calls=[], usage={}),
|
||||
])
|
||||
session = loop.sessions.get_or_create("websocket:target")
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending.put(_session_message(loop))
|
||||
await pending.put(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="target",
|
||||
content="One more detail",
|
||||
))
|
||||
request = RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
turn_id="active-turn",
|
||||
workspace=tmp_path,
|
||||
)
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "Initial request"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
pending_queue=pending,
|
||||
request_context=request,
|
||||
)
|
||||
|
||||
assert SESSION_MESSAGE_METADATA_KEY not in request.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordinary_and_session_injections_remain_separate(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="First", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Second", tool_calls=[], usage={}),
|
||||
])
|
||||
session = loop.sessions.get_or_create("websocket:target")
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending.put(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="target",
|
||||
content="Ordinary follow-up",
|
||||
))
|
||||
await pending.put(_session_message(loop))
|
||||
|
||||
_, _, messages, _, _ = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "Initial request"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
pending_queue=pending,
|
||||
)
|
||||
|
||||
injected = [message for message in messages if message.get("role") == "user"][1:]
|
||||
assert len(injected) == 2
|
||||
persisted_ordinary = {
|
||||
**injected[0],
|
||||
RUNTIME_CONTEXT_HISTORY_META: injected[0]["_meta"]["runtime_context"],
|
||||
}
|
||||
assert public_history_message(persisted_ordinary)["content"] == "Ordinary follow-up"
|
||||
assert SESSION_MESSAGE_METADATA_KEY not in injected[0]
|
||||
assert injected[1][SESSION_MESSAGE_METADATA_KEY]["message_id"] == "handle-message-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_session_inputs_drain_in_one_iteration(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="First", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Second", tool_calls=[], usage={}),
|
||||
])
|
||||
session = loop.sessions.get_or_create("websocket:target")
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending.put(_session_message(loop, "First update"))
|
||||
await pending.put(_session_message(
|
||||
loop,
|
||||
"Second update",
|
||||
message_id="handle-message-2",
|
||||
))
|
||||
|
||||
_, _, messages, _, _ = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "Initial request"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="websocket",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
pending_queue=pending,
|
||||
)
|
||||
|
||||
injected = [message for message in messages if message.get("role") == "user"][1:]
|
||||
assert loop.provider.chat_with_retry.await_count == 2
|
||||
assert [
|
||||
message[SESSION_MESSAGE_METADATA_KEY]["message_id"]
|
||||
for message in injected
|
||||
] == ["handle-message-1", "handle-message-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mid_turn_non_webui_session_input_keeps_source_guidance(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="First", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Second", tool_calls=[], usage={}),
|
||||
])
|
||||
target_key = "telegram:target"
|
||||
loop.sessions.save(loop.sessions.get_or_create(target_key))
|
||||
session = loop.sessions.get_or_create(target_key)
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
await pending.put(_session_message(loop, target_key=target_key))
|
||||
|
||||
_, _, messages, _, _ = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "Initial request"}],
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
channel="telegram",
|
||||
chat_id="target",
|
||||
session_key=session.key,
|
||||
pending_queue=pending,
|
||||
)
|
||||
|
||||
source = SessionHandleDirectory(loop.sessions).ensure_many(["websocket:source"])[
|
||||
"websocket:source"
|
||||
]
|
||||
injected = [message for message in messages if message.get("role") == "user"][-1]
|
||||
assert f"Message from @{source.name}." in str(injected["content"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_timeout_resumes_waiter_with_private_guidance(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
|
||||
response = await loop._process_message(_session_reply_timeout_message(loop))
|
||||
|
||||
assert response is not None
|
||||
assert (response.channel, response.chat_id) == ("websocket", "source")
|
||||
directory = SessionHandleDirectory(loop.sessions)
|
||||
handles = directory.ensure_many(["websocket:source", "websocket:target"])
|
||||
waiter_name = handles["websocket:source"].name
|
||||
target_name = handles["websocket:target"].name
|
||||
expected_provider_input = (
|
||||
f"Your handle: @{waiter_name}.\n\n"
|
||||
f"No reply from @{target_name} after 60s."
|
||||
)
|
||||
session = loop.sessions.get_or_create("websocket:source")
|
||||
timeout_input = next(
|
||||
message for message in session.messages if message.get("role") == "user"
|
||||
)
|
||||
assert timeout_input["content"] == expected_provider_input
|
||||
assert public_history_message(timeout_input)["content"] == ""
|
||||
assert SESSION_REPLY_TIMEOUT_METADATA_KEY in timeout_input
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_uses_persisted_target_workspace(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
project = tmp_path / "target-project"
|
||||
project.mkdir()
|
||||
target = loop.sessions.get_or_create("websocket:target")
|
||||
target.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
||||
"project_path": str(project),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
loop.sessions.save(target)
|
||||
build_messages = MagicMock(wraps=loop.context.build_messages)
|
||||
loop.context.build_messages = build_messages # type: ignore[method-assign]
|
||||
|
||||
await loop._process_message(_session_message(loop))
|
||||
|
||||
assert build_messages.call_args.kwargs["workspace"] == project.resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_input_uses_existing_mid_turn_injection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=20)
|
||||
loop._pending_queues["websocket:target"] = pending
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
message = _session_message(loop, "handle")
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(message)
|
||||
injected = await asyncio.wait_for(pending.get(), timeout=2)
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
assert injected.content == message.content
|
||||
assert (injected.channel, injected.chat_id) == ("websocket", "target")
|
||||
loop._dispatch.assert_not_awaited()
|
||||
transcript = read_transcript_lines("websocket:target")
|
||||
assert len(transcript) == 1
|
||||
assert transcript[0]["text"] == "handle"
|
||||
assert transcript[0]["session_message"]["message_id"] == "handle-message-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_session_input_blocks_idle_compaction(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
session_started = asyncio.Event()
|
||||
release_session = asyncio.Event()
|
||||
|
||||
async def process(_msg: InboundMessage, **_kwargs: object):
|
||||
session_started.set()
|
||||
await release_session.wait()
|
||||
return None
|
||||
|
||||
loop._process_message = process # type: ignore[method-assign]
|
||||
loop.auto_compact.check_expired = MagicMock() # type: ignore[method-assign]
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(_session_message(loop))
|
||||
await asyncio.wait_for(session_started.wait(), timeout=2)
|
||||
|
||||
assert "websocket:target" in loop._pending_queues
|
||||
loop._next_idle_compact_check_at = 0
|
||||
loop._check_expired_sessions_if_due()
|
||||
active_keys = loop.auto_compact.check_expired.call_args.kwargs[
|
||||
"active_session_keys"
|
||||
]
|
||||
assert "websocket:target" in active_keys
|
||||
|
||||
loop.stop()
|
||||
release_session.set()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_slash_text_uses_normal_user_command_router(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
loop._dispatch_command_inline = AsyncMock() # type: ignore[method-assign]
|
||||
message = _session_message(loop, "/stop")
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(message)
|
||||
for _ in range(40):
|
||||
if loop._dispatch.await_count:
|
||||
break
|
||||
await asyncio.sleep(0.025)
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
loop._dispatch_command_inline.assert_awaited_once()
|
||||
loop._dispatch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_session_message_does_not_recreate_deleted_target(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop._concurrency_gate = asyncio.Semaphore(0)
|
||||
|
||||
task = asyncio.create_task(loop._dispatch(_session_message(loop)))
|
||||
await asyncio.sleep(0)
|
||||
assert loop.sessions.delete_session("websocket:target") is True
|
||||
loop._concurrency_gate.release()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
assert loop.sessions.read_session_metadata("websocket:target") is None
|
||||
assert loop.sessions.get_cached("websocket:target") is None
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_target_fails_after_running_session_input_finishes(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
completed = asyncio.Event()
|
||||
|
||||
async def finish_after_delete(*_args: object, **_kwargs: object) -> LLMResponse:
|
||||
started.set()
|
||||
await release.wait()
|
||||
completed.set()
|
||||
return LLMResponse(content="Late result", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_with_retry = finish_after_delete
|
||||
task = asyncio.create_task(loop._process_message(_session_message(loop)))
|
||||
await asyncio.wait_for(started.wait(), timeout=2)
|
||||
|
||||
assert loop.sessions.delete_session("websocket:target") is True
|
||||
release.set()
|
||||
with pytest.raises(RuntimeError, match="session was deleted"):
|
||||
assert response.content == "Reviewed"
|
||||
loop.provider.chat_with_retry.assert_awaited_once()
|
||||
finally:
|
||||
loop.stop()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
assert completed.is_set()
|
||||
assert loop.sessions.read_session_metadata("websocket:target") is None
|
||||
assert loop.sessions.get_cached("websocket:target") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_session_message_allows_target_workspace_change(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop._concurrency_gate = asyncio.Semaphore(0)
|
||||
message = _session_message(loop)
|
||||
|
||||
task = asyncio.create_task(loop._dispatch(message))
|
||||
await asyncio.sleep(0)
|
||||
moved = tmp_path / "moved"
|
||||
moved.mkdir()
|
||||
target = loop.sessions.get_or_create("websocket:target")
|
||||
target.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
||||
"project_path": str(moved),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
loop.sessions.save(target)
|
||||
loop._concurrency_gate.release()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
loop.provider.chat_with_retry.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_webui_session_drops_bus_backlog(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
message = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="target",
|
||||
content="Already accepted by WebUI",
|
||||
require_existing_session=True,
|
||||
)
|
||||
await loop.bus.publish_inbound(message)
|
||||
assert loop.sessions.delete_session("websocket:target") is True
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0.1)
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
assert loop.sessions.read_session_metadata("websocket:target") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_metadata_cannot_spoof_session_message_command_bypass(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
loop._dispatch_command_inline = AsyncMock() # type: ignore[method-assign]
|
||||
internal = _session_message(loop, "/stop")
|
||||
forged = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="target",
|
||||
content="/stop",
|
||||
metadata=internal.metadata,
|
||||
)
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(forged)
|
||||
for _ in range(40):
|
||||
if loop._dispatch_command_inline.await_count:
|
||||
break
|
||||
await asyncio.sleep(0.025)
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
loop._dispatch_command_inline.assert_awaited_once()
|
||||
loop._dispatch.assert_not_awaited()
|
||||
|
||||
@@ -7,14 +7,9 @@ from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
)
|
||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
@@ -84,6 +79,36 @@ def test_websocket_lifecycle_reuses_registered_ingress_owner(tmp_path: Path) ->
|
||||
wth.clear_websocket_turn_if_current("chat-queued", owner)
|
||||
|
||||
|
||||
def test_internal_user_input_uses_the_persisted_webui_route(tmp_path: Path) -> None:
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
target = sessions.get_or_create("websocket:target")
|
||||
target.metadata["webui"] = True
|
||||
sessions.save(target)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(sessions),
|
||||
)
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id="websocket:target",
|
||||
content="Review this",
|
||||
session_key_override="websocket:target",
|
||||
input_role="user",
|
||||
)
|
||||
|
||||
delivery = factory.create(msg, msg.session_key)
|
||||
|
||||
assert (delivery.route.channel, delivery.route.chat_id) == ("websocket", "target")
|
||||
assert delivery.route.publish_lifecycle
|
||||
assert delivery.route.metadata["_wants_stream"] is True
|
||||
owner = delivery.route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
wth.clear_websocket_turn_if_current("target", owner)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_chat_different_sessions_restore_previous_active_projection(
|
||||
tmp_path: Path,
|
||||
@@ -203,147 +228,3 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": "sub-1",
|
||||
}
|
||||
|
||||
|
||||
def test_session_route_targets_its_webui_session_with_source_provenance(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path)
|
||||
session_key = "websocket:reviewer"
|
||||
session = sessions.get_or_create(session_key)
|
||||
session.metadata["webui"] = True
|
||||
sessions.save(session)
|
||||
envelope = {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"source": {
|
||||
"name": "lead",
|
||||
"session_key": "websocket:lead",
|
||||
"handle_id": "handle_00000000000000000000000000000001",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "reviewer",
|
||||
"session_key": session_key,
|
||||
},
|
||||
}
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id=session_key,
|
||||
content="Review this",
|
||||
session_key_override=session_key,
|
||||
require_existing_session=True,
|
||||
metadata={SESSION_MESSAGE_METADATA_KEY: envelope},
|
||||
)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(sessions),
|
||||
)
|
||||
|
||||
route = factory.create(msg, session_key).route
|
||||
|
||||
assert route.channel == "websocket"
|
||||
assert route.chat_id == "reviewer"
|
||||
assert route.publish_lifecycle is True
|
||||
assert route.metadata[SESSION_MESSAGE_METADATA_KEY] == envelope
|
||||
assert route.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
|
||||
"kind": "session",
|
||||
"label": "@lead",
|
||||
}
|
||||
assert route.metadata[WEBUI_TURN_METADATA_KEY].startswith("session-message:")
|
||||
assert msg.metadata == {SESSION_MESSAGE_METADATA_KEY: envelope}
|
||||
|
||||
|
||||
def test_session_timeout_route_resumes_its_webui_session(tmp_path: Path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
session_key = "websocket:lead"
|
||||
session = sessions.get_or_create(session_key)
|
||||
session.metadata["webui"] = True
|
||||
sessions.save(session)
|
||||
envelope = {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"timeout_seconds": 60,
|
||||
"source": {
|
||||
"name": "lead",
|
||||
"session_key": session_key,
|
||||
"handle_id": "handle_00000000000000000000000000000001",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
},
|
||||
}
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session_timeout",
|
||||
chat_id=session_key,
|
||||
content="",
|
||||
session_key_override=session_key,
|
||||
require_existing_session=True,
|
||||
metadata={SESSION_REPLY_TIMEOUT_METADATA_KEY: envelope},
|
||||
)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(sessions),
|
||||
)
|
||||
|
||||
route = factory.create(msg, session_key).route
|
||||
|
||||
assert route.channel == "websocket"
|
||||
assert route.chat_id == "lead"
|
||||
assert route.publish_lifecycle is True
|
||||
assert route.metadata[WEBUI_TURN_METADATA_KEY].startswith("session-reply-timeout:")
|
||||
assert WEBUI_MESSAGE_SOURCE_METADATA_KEY not in route.metadata
|
||||
|
||||
|
||||
def test_session_route_does_not_create_a_missing_target_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
sessions = SessionManager(tmp_path)
|
||||
session_key = "websocket:deleted"
|
||||
envelope = {
|
||||
"message_id": "handle-message-deleted",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": True,
|
||||
"source": {
|
||||
"name": "lead",
|
||||
"session_key": "websocket:lead",
|
||||
"handle_id": "handle_00000000000000000000000000000001",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "deleted",
|
||||
"session_key": session_key,
|
||||
},
|
||||
}
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id=session_key,
|
||||
content="Review this",
|
||||
session_key_override=session_key,
|
||||
require_existing_session=True,
|
||||
metadata={SESSION_MESSAGE_METADATA_KEY: envelope},
|
||||
)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(sessions),
|
||||
)
|
||||
|
||||
route = factory.create(msg, session_key).route
|
||||
|
||||
assert route.publish_lifecycle is False
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert sessions.read_session_metadata(session_key) is None
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import json
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -14,9 +13,8 @@ from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.sessions import ReadSessionTool, SearchSessionsTool
|
||||
from nanobot.runtime_context import RuntimeContextBlock, append_runtime_context
|
||||
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_handles import session_handle_for_key
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
|
||||
@@ -43,14 +41,11 @@ def _decode(value: str) -> dict[str, object]:
|
||||
|
||||
def _webui_request(
|
||||
session_key: str = "websocket:current",
|
||||
*,
|
||||
workspace: Path | None = None,
|
||||
) -> AbstractContextManager[RequestContext]:
|
||||
return request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id=session_key.removeprefix("websocket:"),
|
||||
session_key=session_key,
|
||||
workspace=workspace,
|
||||
))
|
||||
|
||||
|
||||
@@ -141,7 +136,10 @@ async def test_search_sessions_has_no_hidden_content_scan_cutoff(tmp_path, monke
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sessions_ranks_titles_before_message_matches(tmp_path):
|
||||
async def test_search_sessions_ranks_titles_before_message_matches(tmp_path, monkeypatch):
|
||||
webui_dir = tmp_path / "webui"
|
||||
monkeypatch.setattr("nanobot.webui.transcript.get_webui_dir", lambda: webui_dir)
|
||||
monkeypatch.setattr("nanobot.webui.session_list_index.get_webui_dir", lambda: webui_dir)
|
||||
manager = SessionManager(tmp_path)
|
||||
_save_session(
|
||||
manager,
|
||||
@@ -237,62 +235,6 @@ async def test_read_session_filters_by_query_and_returns_recent_matches(tmp_path
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_session_accepts_a_cross_workspace_session_handle(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
other = tmp_path / "other"
|
||||
project.mkdir()
|
||||
other.mkdir()
|
||||
manager = SessionManager(tmp_path / "state")
|
||||
for key, workspace, content in (
|
||||
("websocket:current", project, "current"),
|
||||
("websocket:handle", project, "handle answer"),
|
||||
("websocket:other", other, "other answer"),
|
||||
):
|
||||
_save_session(
|
||||
manager,
|
||||
key,
|
||||
title=key,
|
||||
messages=[{"role": "assistant", "content": content}],
|
||||
)
|
||||
session = manager.get_or_create(key)
|
||||
session.metadata.update({
|
||||
"webui": True,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(workspace),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
})
|
||||
manager.save(session)
|
||||
directory = SessionHandleDirectory(manager)
|
||||
handles = directory.ensure_many([
|
||||
"websocket:current",
|
||||
"websocket:handle",
|
||||
"websocket:other",
|
||||
])
|
||||
current = handles["websocket:current"]
|
||||
handle = handles["websocket:handle"]
|
||||
outside = handles["websocket:other"]
|
||||
tool = ReadSessionTool(manager)
|
||||
|
||||
with _webui_request(workspace=project):
|
||||
result = _decode(await tool.execute(session_key=f"@{handle.name}"))
|
||||
outside_result = _decode(await tool.execute(session_key=f"@{outside.name}"))
|
||||
self_read = await tool.execute(session_key=f"@{current.name}")
|
||||
|
||||
assert result["handle"] == f"@{handle.name}"
|
||||
assert "session_key" not in result
|
||||
assert "session_ref" not in result
|
||||
assert "title" not in result
|
||||
assert "websocket:" not in json.dumps(result)
|
||||
assert result["messages"][0]["content"] == "handle answer"
|
||||
assert outside_result["handle"] == f"@{outside.name}"
|
||||
assert outside_result["messages"][0]["content"] == "other answer"
|
||||
assert "websocket:" not in json.dumps(outside_result)
|
||||
assert self_read.is_error and f"@{current.name}" in str(self_read)
|
||||
assert "websocket:" not in str(self_read)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_session_reports_invalid_requests(tmp_path):
|
||||
with _webui_request():
|
||||
@@ -330,22 +272,15 @@ async def test_session_tools_read_persisted_sessions_from_any_channel(tmp_path):
|
||||
messages=[{"role": "user", "content": "needle"}],
|
||||
)
|
||||
tools = SearchSessionsTool(manager), ReadSessionTool(manager)
|
||||
slack_handle = SessionHandleDirectory(manager).ensure_many(["slack:history"])[
|
||||
"slack:history"
|
||||
]
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="telegram",
|
||||
chat_id="external",
|
||||
session_key="telegram:external",
|
||||
workspace=tmp_path,
|
||||
)):
|
||||
search = _decode(await tools[0].execute(query="needle"))
|
||||
websocket_read = _decode(await tools[1].execute(session_key="websocket:visible"))
|
||||
slack_read = _decode(await tools[1].execute(session_key="slack:history"))
|
||||
slack_handle_read = _decode(
|
||||
await tools[1].execute(session_key=f"@{slack_handle.name}")
|
||||
)
|
||||
current_read = await tools[1].execute(session_key="telegram:external")
|
||||
|
||||
assert {row["session_key"] for row in search["results"]} == {
|
||||
@@ -354,13 +289,35 @@ async def test_session_tools_read_persisted_sessions_from_any_channel(tmp_path):
|
||||
}
|
||||
assert websocket_read["session_key"] == "websocket:visible"
|
||||
assert slack_read["session_key"] == "slack:history"
|
||||
assert slack_handle_read["handle"] == f"@{slack_handle.name}"
|
||||
assert slack_handle_read["messages"][0]["content"] == "needle"
|
||||
assert current_read.is_error and "session not found" in str(current_read)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_tools_work_without_request_context(tmp_path):
|
||||
async def test_read_session_accepts_a_persisted_session_handle(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
_save_session(
|
||||
manager,
|
||||
"slack:history",
|
||||
title="Slack history",
|
||||
messages=[{"role": "user", "content": "needle"}],
|
||||
)
|
||||
handle = session_handle_for_key("slack:history")
|
||||
|
||||
with _webui_request():
|
||||
result = _decode(await ReadSessionTool(manager).execute(
|
||||
session_key=f"@{handle.name}",
|
||||
))
|
||||
|
||||
assert result["handle"] == f"@{handle.name}"
|
||||
assert [message["content"] for message in result["messages"]] == ["needle"]
|
||||
assert "session_key" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_tools_work_without_request_context(tmp_path, monkeypatch):
|
||||
webui_dir = tmp_path / "webui"
|
||||
monkeypatch.setattr("nanobot.webui.transcript.get_webui_dir", lambda: webui_dir)
|
||||
monkeypatch.setattr("nanobot.webui.session_list_index.get_webui_dir", lambda: webui_dir)
|
||||
manager = SessionManager(tmp_path)
|
||||
_save_session(
|
||||
manager,
|
||||
|
||||
Reference in New Issue
Block a user