feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+9 -3
View File
@@ -23,6 +23,7 @@ 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,
@@ -853,11 +854,16 @@ class TestToolEventProgress:
assert len(requests) == 2
assert requests[0][-1]["role"] == "user"
assert requests[0][-1]["content"].endswith("Background research completed")
assert any(
message.get("role") == "user"
and message.get("content") == "Can you include the key detail?"
follow_up = next(
message
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
+85 -1
View File
@@ -1,8 +1,13 @@
"""Tests for SessionManager.delete_session and read_session_file."""
from pathlib import Path
from threading import Event, Thread
from nanobot.session.manager import Session, SessionManager
from nanobot.session.manager import (
SESSION_MODEL_PRESET_METADATA_KEY,
Session,
SessionManager,
)
def _seed(workspace: Path, key: str = "telegram:abc") -> SessionManager:
@@ -29,6 +34,85 @@ 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
+645
View File
@@ -0,0 +1,645 @@
"""Session-authored user input behavior."""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
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",
)
def _loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = SimpleNamespace(max_tokens=4096)
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="Reviewed", tool_calls=[], usage={})
)
loop = 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"]
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,
)
@pytest.mark.asyncio
async def test_session_input_keeps_reply_guidance_private_runtime_context(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
loop = _loop(tmp_path)
loop.sessions.invalidate("websocket:target")
response = await loop._process_message(_session_message(loop))
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."
)
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"
)
assert provider_input["content"] == expected_provider_input
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"]
@pytest.mark.asyncio
async def test_session_input_runs_as_user_turn_for_non_websocket_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
loop = _loop(tmp_path)
target_key = "telegram:target"
loop.sessions.save(loop.sessions.get_or_create(target_key))
loop.sessions.invalidate(target_key)
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"):
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()
+149
View File
@@ -7,9 +7,14 @@ 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,
)
@@ -198,3 +203,147 @@ 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
+71
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from contextlib import AbstractContextManager
from datetime import datetime
from pathlib import Path
import pytest
@@ -13,7 +14,9 @@ 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.webui.transcript import append_transcript_object
@@ -40,11 +43,14 @@ 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,
))
@@ -231,6 +237,62 @@ 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():
@@ -268,15 +330,22 @@ 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"]} == {
@@ -285,6 +354,8 @@ 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)