mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
719 lines
23 KiB
Python
719 lines
23 KiB
Python
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",
|
|
},
|
|
}
|
|
}
|