refactor: simplify cross-session messaging

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 0e184965e8
commit 251a1ccd40
78 changed files with 1578 additions and 7569 deletions
+3 -9
View File
@@ -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 -85
View File
@@ -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
+48 -592
View File
@@ -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()
+30 -149
View File
@@ -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
+30 -73
View File
@@ -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,