mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 17:52:00 +03:00
feat(webui): add lightweight session messaging via mentions
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import (
|
||||
SESSION_HANDLE_DIRECTORY_VERSION,
|
||||
SessionHandleDirectory,
|
||||
SessionHandleDirectoryError,
|
||||
SessionHandleDirectoryProtocol,
|
||||
SessionHandleSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def _save_session(
|
||||
sessions: SessionManager,
|
||||
key: str,
|
||||
*,
|
||||
workspace: Path,
|
||||
title: str = "",
|
||||
) -> None:
|
||||
session = sessions.get_or_create(key)
|
||||
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
||||
"project_path": str(workspace.resolve()),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
if title:
|
||||
session.metadata["title"] = title
|
||||
sessions.save(session, fsync=True)
|
||||
|
||||
|
||||
def test_ensure_persists_public_handle_without_exposing_routing_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(
|
||||
sessions,
|
||||
"websocket:review",
|
||||
workspace=project,
|
||||
title="代码 审查!",
|
||||
)
|
||||
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handle = directory.ensure_many(["websocket:review"])["websocket:review"]
|
||||
reloaded = SessionHandleDirectory(sessions).handle_for_session("websocket:review")
|
||||
|
||||
assert isinstance(directory, SessionHandleDirectoryProtocol)
|
||||
assert handle.name.isascii() and handle.name.isalpha() and handle.name.islower()
|
||||
assert handle.session_key == "websocket:review"
|
||||
assert handle.workspace == project.resolve()
|
||||
assert 0 <= handle.color_slot < 8
|
||||
assert handle.public_payload() == {
|
||||
"id": handle.id,
|
||||
"name": handle.name,
|
||||
"color_slot": handle.color_slot,
|
||||
}
|
||||
assert reloaded == handle
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
assert stored["version"] == SESSION_HANDLE_DIRECTORY_VERSION
|
||||
assert stored["handles"][0]["session_key"] == "websocket:review"
|
||||
|
||||
_save_session(
|
||||
sessions,
|
||||
"websocket:review",
|
||||
workspace=project,
|
||||
title="A completely different title",
|
||||
)
|
||||
assert directory.ensure_many(["websocket:review"])["websocket:review"] == handle
|
||||
|
||||
|
||||
def test_snapshot_batch_uses_one_write_without_session_metadata_reads(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
keys = [f"websocket:{index}" for index in range(4)]
|
||||
for key in keys:
|
||||
_save_session(sessions, key, workspace=project, title="Worker")
|
||||
snapshots = [
|
||||
SessionHandleSnapshot(
|
||||
session_key=key,
|
||||
workspace=project,
|
||||
)
|
||||
for key in keys
|
||||
]
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
real_save = directory._save_unlocked
|
||||
writes = 0
|
||||
|
||||
def count_save(records) -> None:
|
||||
nonlocal writes
|
||||
writes += 1
|
||||
real_save(records)
|
||||
|
||||
def fail_metadata_read(_key: str) -> None:
|
||||
raise AssertionError("trusted snapshots must not reread individual sessions")
|
||||
|
||||
monkeypatch.setattr(directory, "_save_unlocked", count_save)
|
||||
monkeypatch.setattr(sessions, "read_session_metadata", fail_metadata_read)
|
||||
|
||||
first = directory.ensure_snapshot_many(snapshots)
|
||||
second = directory.ensure_snapshot_many(snapshots)
|
||||
reloaded = SessionHandleDirectory(sessions).ensure_snapshot_many(snapshots)
|
||||
|
||||
assert writes == 1
|
||||
assert second == first
|
||||
assert reloaded == first
|
||||
assert len({handle.id for handle in first.values()}) == len(keys)
|
||||
assert len({handle.name for handle in first.values()}) == len(keys)
|
||||
assert all(
|
||||
handle.name.isascii() and handle.name.isalpha() and handle.name.islower()
|
||||
for handle in first.values()
|
||||
)
|
||||
|
||||
|
||||
def test_names_are_globally_unique_and_resolve_across_workspaces(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
left = tmp_path / "left"
|
||||
right = tmp_path / "right"
|
||||
left.mkdir()
|
||||
right.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(sessions, "websocket:left", workspace=left, title="Reviewer")
|
||||
_save_session(sessions, "websocket:right", workspace=right, title="Reviewer")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
|
||||
left_handle = directory.ensure_many(["websocket:left"])["websocket:left"]
|
||||
right_handle = directory.ensure_many(["websocket:right"])["websocket:right"]
|
||||
|
||||
assert left_handle.name != right_handle.name
|
||||
assert directory.resolve(f"@{left_handle.name}") == left_handle
|
||||
assert directory.resolve(right_handle.name) == right_handle
|
||||
assert directory.resolve("missing") is None
|
||||
|
||||
|
||||
def test_legacy_cross_workspace_name_collision_is_repaired(tmp_path: Path) -> None:
|
||||
left = tmp_path / "left"
|
||||
right = tmp_path / "right"
|
||||
left.mkdir()
|
||||
right.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(sessions, "websocket:left", workspace=left, title="Left")
|
||||
_save_session(sessions, "websocket:right", workspace=right, title="Right")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many(["websocket:left", "websocket:right"])
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
stored["handles"][1]["name"] = stored["handles"][0]["name"]
|
||||
directory.store_path.write_text(json.dumps(stored), encoding="utf-8")
|
||||
|
||||
repaired = SessionHandleDirectory(sessions).list_all()
|
||||
|
||||
assert {handle.id for handle in repaired} == {handle.id for handle in handles.values()}
|
||||
assert len({handle.name for handle in repaired}) == 2
|
||||
|
||||
|
||||
def test_handles_are_casefold_unique_and_rename_is_not_exposed(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(sessions, "websocket:first", workspace=project, title="Straße")
|
||||
_save_session(sessions, "websocket:second", workspace=project, title="STRASSE")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
|
||||
first = directory.ensure_many(["websocket:first"])["websocket:first"]
|
||||
second = directory.ensure_many(["websocket:second"])["websocket:second"]
|
||||
|
||||
assert first.name.casefold() != second.name.casefold()
|
||||
assert not hasattr(directory, "rename")
|
||||
|
||||
|
||||
def test_concurrent_allocation_keeps_names_unique(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
keys = [f"websocket:{index}" for index in range(20)]
|
||||
for key in keys:
|
||||
_save_session(sessions, key, workspace=project, title="Worker")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
handles = list(
|
||||
executor.map(lambda key: directory.ensure_many([key])[key], keys)
|
||||
)
|
||||
|
||||
assert len({handle.id for handle in handles}) == len(keys)
|
||||
assert len({handle.name.casefold() for handle in handles}) == len(keys)
|
||||
assert all(
|
||||
handle.name.isascii() and handle.name.isalpha() and handle.name.islower()
|
||||
for handle in handles
|
||||
)
|
||||
assert len(SessionHandleDirectory(sessions).list_all()) == len(keys)
|
||||
|
||||
|
||||
def test_scope_change_rehomes_handle_and_avoids_destination_collision(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.session_handles._HANDLE_NAMES",
|
||||
("mira",),
|
||||
)
|
||||
left = tmp_path / "left"
|
||||
right = tmp_path / "right"
|
||||
left.mkdir()
|
||||
right.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(sessions, "websocket:moving", workspace=left, title="Worker")
|
||||
_save_session(sessions, "websocket:resident", workspace=right, title="Worker")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
moving = directory.ensure_many(["websocket:moving"])["websocket:moving"]
|
||||
resident = directory.ensure_many(["websocket:resident"])["websocket:resident"]
|
||||
|
||||
_save_session(sessions, "websocket:moving", workspace=right, title="Worker")
|
||||
moved = directory.ensure_many(["websocket:moving"])["websocket:moving"]
|
||||
|
||||
assert moved.id == moving.id
|
||||
assert moved.workspace == right.resolve()
|
||||
assert moved.name == moving.name == "mira"
|
||||
assert resident.name == "mira-2"
|
||||
assert directory.resolve("mira") == moved
|
||||
assert directory.resolve("mira-2") == resident
|
||||
|
||||
|
||||
def test_pool_exhaustion_adds_a_short_numeric_suffix(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.session_handles._HANDLE_NAMES",
|
||||
("mira", "nora"),
|
||||
)
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
keys = [f"websocket:{index}" for index in range(3)]
|
||||
for key in keys:
|
||||
_save_session(sessions, key, workspace=project, title="Same title")
|
||||
|
||||
handles = SessionHandleDirectory(sessions).ensure_many(keys)
|
||||
|
||||
assert {handles[key].name for key in keys[:2]} == {"mira", "nora"}
|
||||
assert handles[keys[2]].name in {"mira-2", "nora-2"}
|
||||
|
||||
|
||||
def test_missing_session_is_removed_when_resolution_finds_stale_record(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
key = "websocket:stale"
|
||||
_save_session(sessions, key, workspace=project, title="Stale")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handle = directory.ensure_many([key])[key]
|
||||
assert sessions.delete_session(key) is True
|
||||
|
||||
assert directory.resolve(handle.name) is None
|
||||
assert directory.handle_for_session(key) is None
|
||||
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
|
||||
assert stored["handles"] == []
|
||||
|
||||
|
||||
def test_atomic_write_tolerates_unsupported_directory_fsync(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
_save_session(sessions, "websocket:shared", workspace=project, title="Shared")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
real_open = os.open
|
||||
real_close = os.close
|
||||
real_fsync = os.fsync
|
||||
directory_fds: set[int] = set()
|
||||
|
||||
def fake_open(path: str, flags: int, *args: object, **kwargs: object) -> int:
|
||||
fd = real_open(path, flags, *args, **kwargs)
|
||||
if Path(path) == directory.store_path.parent:
|
||||
directory_fds.add(fd)
|
||||
return fd
|
||||
|
||||
def fake_fsync(fd: int) -> None:
|
||||
if fd in directory_fds:
|
||||
raise OSError(errno.EINVAL, "Invalid argument")
|
||||
real_fsync(fd)
|
||||
|
||||
def fake_close(fd: int) -> None:
|
||||
directory_fds.discard(fd)
|
||||
real_close(fd)
|
||||
|
||||
monkeypatch.setattr(os, "open", fake_open)
|
||||
monkeypatch.setattr(os, "close", fake_close)
|
||||
monkeypatch.setattr(os, "fsync", fake_fsync)
|
||||
|
||||
handle = directory.ensure_many(["websocket:shared"])["websocket:shared"]
|
||||
|
||||
assert SessionHandleDirectory(sessions).handle_for_session(handle.session_key) == handle
|
||||
|
||||
|
||||
def test_corrupt_store_is_rejected_without_overwriting_it(tmp_path: Path) -> None:
|
||||
sessions = SessionManager(tmp_path / "agent")
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
directory.store_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SessionHandleDirectoryError):
|
||||
directory.list_all()
|
||||
|
||||
assert directory.store_path.read_text(encoding="utf-8") == "{broken"
|
||||
@@ -192,17 +192,17 @@ def test_copied_workspace_gets_isolated_session_identity(tmp_path: Path) -> None
|
||||
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
|
||||
real_workspace = tmp_path / "real_ws"
|
||||
real_workspace.mkdir()
|
||||
link_workspace = tmp_path / "link_ws"
|
||||
link_workspace.symlink_to(real_workspace, target_is_directory=True)
|
||||
equivalent_workspace = real_workspace / ".." / real_workspace.name
|
||||
|
||||
# Save via the real path, then read via a symlink to the same directory.
|
||||
# Save via the canonical path, then read via a lexical alias to the same directory.
|
||||
manager = SessionManager(workspace=real_workspace)
|
||||
session = manager.get_or_create("telegram:1")
|
||||
session.add_message("user", "via-real")
|
||||
manager.save(session)
|
||||
|
||||
via_link = SessionManager(workspace=link_workspace).get_or_create("telegram:1")
|
||||
assert via_link.messages[-1]["content"] == "via-real"
|
||||
via_equivalent = SessionManager(workspace=equivalent_workspace)
|
||||
assert via_equivalent.sessions_dir == manager.sessions_dir
|
||||
assert via_equivalent.get_or_create("telegram:1").messages[-1]["content"] == "via-real"
|
||||
|
||||
|
||||
def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.session_messages import SendSessionMessageTool
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import SessionMessageInputEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import SessionHandle, SessionHandleDirectory
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
SessionMessageError,
|
||||
session_message_envelope,
|
||||
session_message_inbound,
|
||||
session_reply_timeout_envelope,
|
||||
session_reply_timeout_inbound,
|
||||
)
|
||||
from nanobot.session.webui_turns import project_session_message_input
|
||||
from nanobot.webui.transcript import read_transcript_lines
|
||||
|
||||
|
||||
class _FakeTimer:
|
||||
def __init__(self) -> None:
|
||||
self.cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[float, Callable[[], None], _FakeTimer]] = []
|
||||
|
||||
def __call__(self, delay: float, callback: Callable[[], None]) -> _FakeTimer:
|
||||
timer = _FakeTimer()
|
||||
self.calls.append((delay, callback, timer))
|
||||
return timer
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class FakeSessionHandleDirectory:
|
||||
def __init__(self, identities: list[SessionHandle]) -> None:
|
||||
self._by_key = {identity.session_key: identity for identity in identities}
|
||||
self._by_name = {identity.name.casefold(): identity for identity in identities}
|
||||
|
||||
def ensure(self, session_key: str) -> SessionHandle:
|
||||
identity = self.handle_for_session(session_key)
|
||||
if identity is None:
|
||||
raise ValueError(f"unknown session: {session_key}")
|
||||
return identity
|
||||
|
||||
def resolve(self, name: str) -> SessionHandle | None:
|
||||
return self._by_name.get(name.casefold())
|
||||
|
||||
def handle_for_session(self, key: str) -> SessionHandle | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
|
||||
def _identity(name: str, session_key: str, workspace: Path) -> SessionHandle:
|
||||
color_slot = 1 if name == "lead" else 2
|
||||
return SessionHandle(
|
||||
id=f"handle_{color_slot:032x}",
|
||||
name=name,
|
||||
color_slot=color_slot,
|
||||
session_key=session_key,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
|
||||
def _persist(sessions: SessionManager, key: str) -> None:
|
||||
session = sessions.get_or_create(key)
|
||||
session.metadata["webui"] = True
|
||||
sessions.save(session)
|
||||
|
||||
|
||||
def _service(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
max_messages_per_minute: int = 6,
|
||||
schedule_later: Callable[[float, Callable[[], None]], _FakeTimer] | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> tuple[SendSessionMessageTool, MessageBus, SessionManager]:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source = _identity("lead", "websocket:lead", workspace)
|
||||
target = _identity("reviewer", "websocket:reviewer", workspace)
|
||||
for identity in (source, target):
|
||||
_persist(sessions, identity.session_key)
|
||||
sessions.invalidate(source.session_key)
|
||||
sessions.invalidate(target.session_key)
|
||||
bus = MessageBus()
|
||||
return (
|
||||
SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
directory=FakeSessionHandleDirectory([source, target]),
|
||||
max_messages_per_minute=max_messages_per_minute,
|
||||
schedule_later=schedule_later,
|
||||
clock=clock,
|
||||
),
|
||||
bus,
|
||||
sessions,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_input_is_persisted_and_projected(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.transcript.get_webui_dir", lambda: tmp_path / "webui")
|
||||
service, bus, _sessions = _service(tmp_path)
|
||||
|
||||
target_handle = await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="@reviewer",
|
||||
content="Review the implementation.",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
|
||||
assert target_handle == "@reviewer"
|
||||
inbound = bus.inbound.get_nowait()
|
||||
assert inbound.channel == "websocket"
|
||||
assert inbound.sender_id == "session"
|
||||
assert inbound.chat_id == "reviewer"
|
||||
assert inbound.session_key_override == "websocket:reviewer"
|
||||
assert inbound.require_existing_session is True
|
||||
assert inbound.content == "Review the implementation."
|
||||
assert set(inbound.metadata) == {SESSION_MESSAGE_METADATA_KEY}
|
||||
envelope = session_message_envelope(inbound.metadata)
|
||||
assert envelope is not None
|
||||
assert session_message_inbound(inbound) == envelope
|
||||
message_id = envelope["message_id"]
|
||||
assert envelope["expect_reply"] is True
|
||||
assert set(envelope) == {
|
||||
"message_id",
|
||||
"created_at_ms",
|
||||
"expect_reply",
|
||||
"source",
|
||||
"target",
|
||||
}
|
||||
assert envelope["source"] == {
|
||||
"name": "lead",
|
||||
"session_key": "websocket:lead",
|
||||
"handle_id": "handle_00000000000000000000000000000001",
|
||||
"color_slot": 1,
|
||||
}
|
||||
assert envelope["target"] == {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
}
|
||||
assert bus.outbound.empty()
|
||||
|
||||
await project_session_message_input(bus, inbound, "websocket:reviewer")
|
||||
|
||||
live = bus.outbound.get_nowait()
|
||||
assert (live.channel, live.chat_id) == ("websocket", "reviewer")
|
||||
assert isinstance(live.event, SessionMessageInputEvent)
|
||||
assert live.event.content == "Review the implementation."
|
||||
assert live.event.session_message == {
|
||||
"direction": "incoming",
|
||||
"message_id": message_id,
|
||||
"session": {
|
||||
"id": "handle_00000000000000000000000000000001",
|
||||
"name": "lead",
|
||||
"color_slot": 1,
|
||||
},
|
||||
}
|
||||
assert "websocket:" not in str(live.event.session_message)
|
||||
assert bus.outbound.empty()
|
||||
transcript = read_transcript_lines("websocket:reviewer")
|
||||
assert len(transcript) == 1
|
||||
assert transcript[0]["text"] == "Review the implementation."
|
||||
assert transcript[0]["session_message"] == live.event.session_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_publishes_when_transcript_persistence_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.transcript.get_webui_dir", lambda: tmp_path / "webui")
|
||||
service, bus, _sessions = _service(tmp_path)
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="@reviewer",
|
||||
content="Review the implementation.",
|
||||
expect_reply=False,
|
||||
)
|
||||
inbound = bus.inbound.get_nowait()
|
||||
attempts = 0
|
||||
|
||||
def fail_append(*args: object, **kwargs: object) -> None:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise OSError("write failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.append_session_message_input",
|
||||
fail_append,
|
||||
)
|
||||
|
||||
await project_session_message_input(bus, inbound, "websocket:reviewer")
|
||||
|
||||
assert attempts == 1
|
||||
assert bus.outbound.qsize() == 1
|
||||
assert read_transcript_lines("websocket:reviewer") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_accepts_persisted_target_that_is_not_cached(tmp_path: Path) -> None:
|
||||
service, bus, sessions = _service(tmp_path)
|
||||
assert sessions.get_cached("websocket:reviewer") is None
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Ping",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 1
|
||||
assert sessions.get_cached("websocket:reviewer") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_supports_non_webui_sessions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "telegram:source"
|
||||
source = sessions.get_or_create(source_key)
|
||||
source.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
||||
"project_path": str(workspace),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
sessions.save(source)
|
||||
target_key = "telegram:target"
|
||||
target_session = sessions.get_or_create(target_key)
|
||||
target_session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = {
|
||||
"project_path": str(workspace),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
sessions.save(target_session)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
target = directory.ensure_many([target_key])[target_key]
|
||||
bus = MessageBus()
|
||||
service = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
directory=directory,
|
||||
)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key=source_key,
|
||||
target_handle=target.name,
|
||||
content="Ping",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
inbound = bus.inbound.get_nowait()
|
||||
envelope = session_message_inbound(inbound)
|
||||
assert envelope is not None
|
||||
assert envelope["source"]["session_key"] == source_key
|
||||
assert envelope["target"]["session_key"] == target_key
|
||||
assert directory.handle_for_session(source_key) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_uses_persistent_session_handles(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
for key, title in (
|
||||
("websocket:lead", "Lead"),
|
||||
("websocket:reviewer", "Reviewer"),
|
||||
):
|
||||
session = sessions.get_or_create(key)
|
||||
session.metadata.update({
|
||||
"title": title,
|
||||
"webui": True,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(workspace),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
})
|
||||
sessions.save(session)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many(["websocket:lead", "websocket:reviewer"])
|
||||
source = handles["websocket:lead"]
|
||||
target = handles["websocket:reviewer"]
|
||||
bus = MessageBus()
|
||||
service = SendSessionMessageTool(sessions=sessions, bus=bus, directory=directory)
|
||||
|
||||
target_handle = await service.enqueue(
|
||||
source_session_key=source.session_key,
|
||||
target_handle=f"@{target.name}",
|
||||
content="Please review this.",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
|
||||
assert target_handle == f"@{target.name}"
|
||||
inbound = bus.inbound.get_nowait()
|
||||
envelope = session_message_envelope(inbound.metadata)
|
||||
assert envelope is not None
|
||||
assert envelope["source"]["handle_id"] == source.id
|
||||
assert envelope["target"]["session_key"] == target.session_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_rejects_stale_target_before_bus_mutation(tmp_path: Path) -> None:
|
||||
service, bus, sessions = _service(tmp_path)
|
||||
sessions.delete_session("websocket:reviewer")
|
||||
|
||||
with pytest.raises(SessionMessageError, match="not persisted") as exc_info:
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Ping",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "target_not_found"
|
||||
assert bus.inbound_size == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_allows_self_send(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source = _identity("lead", "websocket:lead", workspace)
|
||||
_persist(sessions, source.session_key)
|
||||
bus = MessageBus()
|
||||
service = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
directory=FakeSessionHandleDirectory([source]),
|
||||
)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key=source.session_key,
|
||||
target_handle="@lead",
|
||||
content="Loop",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_accepts_cross_workspace_target(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
other = tmp_path / "other"
|
||||
workspace.mkdir()
|
||||
other.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source = _identity("lead", "websocket:lead", workspace)
|
||||
target = _identity("reviewer", "websocket:reviewer", other)
|
||||
for identity in (source, target):
|
||||
_persist(sessions, identity.session_key)
|
||||
|
||||
bus = MessageBus()
|
||||
scheduler = _FakeScheduler()
|
||||
service = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
directory=FakeSessionHandleDirectory([source, target]),
|
||||
schedule_later=scheduler,
|
||||
)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key=source.session_key,
|
||||
target_handle="reviewer",
|
||||
content="Ping",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
|
||||
inbound = bus.inbound.get_nowait()
|
||||
envelope = session_message_inbound(inbound)
|
||||
assert envelope is not None
|
||||
assert envelope["source"]["session_key"] == source.session_key
|
||||
assert envelope["target"]["session_key"] == target.session_key
|
||||
|
||||
scheduler.calls[0][1]()
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
timeout = bus.inbound.get_nowait()
|
||||
assert session_reply_timeout_inbound(timeout) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_enforces_per_session_minute_limit(tmp_path: Path) -> None:
|
||||
clock = _FakeClock()
|
||||
service, bus, _sessions = _service(
|
||||
tmp_path,
|
||||
max_messages_per_minute=2,
|
||||
clock=clock,
|
||||
)
|
||||
for index in range(2):
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content=f"Ping {index}",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SessionMessageError) as exc_info:
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Too many",
|
||||
expect_reply=False,
|
||||
)
|
||||
assert exc_info.value.code == "rate_limited"
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:reviewer",
|
||||
target_handle="lead",
|
||||
content="Independent sender",
|
||||
expect_reply=False,
|
||||
)
|
||||
clock.advance(60)
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="New window",
|
||||
expect_reply=False,
|
||||
)
|
||||
assert bus.inbound_size == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_publish_failure_does_not_consume_rate_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, bus, _sessions = _service(tmp_path, max_messages_per_minute=1)
|
||||
original_publish = bus.publish_inbound
|
||||
bus.publish_inbound = AsyncMock(side_effect=RuntimeError("bus unavailable"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="bus unavailable"):
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="first attempt",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
bus.publish_inbound = original_publish
|
||||
target_handle = await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="retry",
|
||||
expect_reply=False,
|
||||
)
|
||||
assert target_handle == "@reviewer"
|
||||
assert bus.inbound_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_requires_timeout_only_for_requested_replies(tmp_path: Path) -> None:
|
||||
service, bus, _sessions = _service(tmp_path)
|
||||
|
||||
with pytest.raises(SessionMessageError) as exc_info:
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Please reply",
|
||||
expect_reply=True,
|
||||
)
|
||||
assert exc_info.value.code == "invalid_reply_timeout"
|
||||
|
||||
with pytest.raises(SessionMessageError) as exc_info:
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Please reply",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=61,
|
||||
)
|
||||
assert exc_info.value.code == "invalid_reply_timeout"
|
||||
|
||||
with pytest.raises(SessionMessageError) as exc_info:
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="No reply needed",
|
||||
expect_reply=False,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
assert exc_info.value.code == "unexpected_reply_timeout"
|
||||
assert bus.inbound_size == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requested_reply_timeout_resumes_the_waiting_session(tmp_path: Path) -> None:
|
||||
scheduler = _FakeScheduler()
|
||||
service, bus, _sessions = _service(tmp_path, schedule_later=scheduler)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Please reply",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
bus.inbound.get_nowait()
|
||||
assert len(scheduler.calls) == 1
|
||||
delay, expire, timer = scheduler.calls[0]
|
||||
assert delay == 60
|
||||
assert timer.cancelled is False
|
||||
|
||||
expire()
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
timeout_message = bus.inbound.get_nowait()
|
||||
timeout = session_reply_timeout_envelope(timeout_message.metadata)
|
||||
assert timeout is not None
|
||||
assert session_reply_timeout_inbound(timeout_message) == timeout
|
||||
assert timeout["timeout_seconds"] == 60
|
||||
assert timeout["source"]["session_key"] == "websocket:lead"
|
||||
assert timeout["target"]["session_key"] == "websocket:reviewer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_reply_cancels_its_pending_timeout(tmp_path: Path) -> None:
|
||||
scheduler = _FakeScheduler()
|
||||
service, bus, _sessions = _service(tmp_path, schedule_later=scheduler)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:lead",
|
||||
target_handle="reviewer",
|
||||
content="Please reply",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
bus.inbound.get_nowait()
|
||||
timer = scheduler.calls[0][2]
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:reviewer",
|
||||
target_handle="lead",
|
||||
content="Here is the answer",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert timer.cancelled is True
|
||||
reply = bus.inbound.get_nowait()
|
||||
assert session_message_inbound(reply) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_reply_wait_replaces_the_previous_wait_for_the_same_session_pair(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scheduler = _FakeScheduler()
|
||||
service, bus, _sessions = _service(tmp_path, schedule_later=scheduler)
|
||||
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:reviewer",
|
||||
target_handle="lead",
|
||||
content="First question",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
await service.enqueue(
|
||||
source_session_key="websocket:reviewer",
|
||||
target_handle="lead",
|
||||
content="Second question",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=30,
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 2
|
||||
assert len(scheduler.calls) == 2
|
||||
assert scheduler.calls[0][2].cancelled is True
|
||||
assert scheduler.calls[1][2].cancelled is False
|
||||
|
||||
scheduler.calls[0][1]()
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert bus.inbound_size == 2
|
||||
|
||||
|
||||
def test_session_message_envelope_rejects_dynamic_boundary_violations() -> None:
|
||||
assert session_message_envelope(None) is None
|
||||
assert session_message_envelope({SESSION_MESSAGE_METADATA_KEY: {}}) is None
|
||||
|
||||
metadata = _session_message_metadata()
|
||||
envelope = metadata[SESSION_MESSAGE_METADATA_KEY]
|
||||
assert isinstance(envelope, dict)
|
||||
envelope.pop("expect_reply")
|
||||
assert session_message_envelope(metadata) is None
|
||||
|
||||
metadata = _session_message_metadata()
|
||||
envelope = metadata[SESSION_MESSAGE_METADATA_KEY]
|
||||
assert isinstance(envelope, dict)
|
||||
envelope["expect_reply"] = 1
|
||||
assert session_message_envelope(metadata) is None
|
||||
|
||||
|
||||
def test_session_inbound_checks_sender_and_route_not_lifecycle_policy() -> None:
|
||||
metadata = _session_message_metadata()
|
||||
internal = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id="websocket:reviewer",
|
||||
content="Review this",
|
||||
metadata=metadata,
|
||||
session_key_override="websocket:reviewer",
|
||||
)
|
||||
forged = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="reviewer",
|
||||
content="/stop",
|
||||
metadata=metadata,
|
||||
session_key_override="websocket:reviewer",
|
||||
require_existing_session=True,
|
||||
)
|
||||
wrong_target = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session",
|
||||
chat_id="websocket:other",
|
||||
content="/stop",
|
||||
metadata=metadata,
|
||||
session_key_override="websocket:other",
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
assert session_message_inbound(internal) is not None
|
||||
assert session_message_inbound(forged) is None
|
||||
assert session_message_inbound(wrong_target) is None
|
||||
|
||||
|
||||
def test_session_reply_timeout_checks_sender_and_route_not_lifecycle_policy() -> None:
|
||||
metadata = _session_message_metadata()
|
||||
request = metadata[SESSION_MESSAGE_METADATA_KEY]
|
||||
assert isinstance(request, dict)
|
||||
timeout_metadata = {
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {
|
||||
**request,
|
||||
"timeout_seconds": 60,
|
||||
},
|
||||
}
|
||||
internal = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session_timeout",
|
||||
chat_id="websocket:lead",
|
||||
content="",
|
||||
metadata=timeout_metadata,
|
||||
session_key_override="websocket:lead",
|
||||
)
|
||||
forged = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="lead",
|
||||
content="",
|
||||
metadata=timeout_metadata,
|
||||
session_key_override="websocket:lead",
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
assert session_reply_timeout_envelope(timeout_metadata) is not None
|
||||
assert session_reply_timeout_inbound(internal) is not None
|
||||
assert session_reply_timeout_inbound(forged) is None
|
||||
|
||||
request["expect_reply"] = False
|
||||
assert session_reply_timeout_envelope({
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {
|
||||
**request,
|
||||
"timeout_seconds": 60,
|
||||
},
|
||||
}) is None
|
||||
|
||||
|
||||
def _session_message_metadata() -> dict[str, object]:
|
||||
return {
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "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": "websocket:reviewer",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user