mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
refactor: simplify cross-session messaging
This commit is contained in:
@@ -1,422 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import ToolResult
|
||||
from nanobot.agent.tools.context import RequestContext, ToolContext, request_context
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.session_messages import ListSessionsTool, SendSessionMessageTool
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.session_messages import (
|
||||
ListSessionsTool,
|
||||
SendSessionMessageTool,
|
||||
SessionMessageError,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import SessionHandleDirectory
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY,
|
||||
session_message_envelope,
|
||||
)
|
||||
|
||||
|
||||
def test_send_session_message_requires_an_explicit_boolean_reply_contract(
|
||||
def _persist(manager: SessionManager, *keys: str) -> None:
|
||||
for key in keys:
|
||||
manager.save(manager.get_or_create(key))
|
||||
|
||||
|
||||
class _Timer:
|
||||
def __init__(self, callback: Callable[[], None]) -> None:
|
||||
self.callback = callback
|
||||
self.cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
def fire(self) -> None:
|
||||
if not self.cancelled:
|
||||
self.callback()
|
||||
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[float, _Timer]] = []
|
||||
|
||||
def __call__(self, delay: float, callback: Callable[[], None]) -> _Timer:
|
||||
timer = _Timer(callback)
|
||||
self.calls.append((delay, timer))
|
||||
return timer
|
||||
|
||||
|
||||
def test_config_and_tool_schema_keep_only_the_basic_reply_contract(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
parameters = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=SessionManager(tmp_path),
|
||||
bus=MessageBus(),
|
||||
).parameters
|
||||
|
||||
assert parameters["required"] == ["to", "content", "expect_reply"]
|
||||
assert parameters["properties"]["expect_reply"]["type"] == "boolean"
|
||||
timeout = parameters["properties"]["reply_timeout_seconds"]
|
||||
assert (timeout["type"], timeout["minimum"], timeout["maximum"]) == (
|
||||
"integer",
|
||||
5,
|
||||
60,
|
||||
)
|
||||
|
||||
|
||||
def test_session_message_rate_limit_config_defaults_to_six_per_minute() -> None:
|
||||
assert ToolsConfig().max_session_messages_per_minute == 6
|
||||
configured = ToolsConfig.model_validate({"maxSessionMessagesPerMinute": 9})
|
||||
assert configured.max_session_messages_per_minute == 9
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ToolsConfig(max_session_messages_per_minute=0)
|
||||
|
||||
|
||||
def _session_message_metadata(*, expect_reply: bool = True) -> dict[str, object]:
|
||||
return {
|
||||
SESSION_MESSAGE_METADATA_KEY: {
|
||||
"message_id": "handle-message-1",
|
||||
"created_at_ms": 1,
|
||||
"expect_reply": expect_reply,
|
||||
"source": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
"handle_id": "handle_reviewer",
|
||||
"color_slot": 1,
|
||||
},
|
||||
"target": {
|
||||
"name": "author",
|
||||
"session_key": "websocket:author",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _reply_timeout_metadata() -> dict[str, object]:
|
||||
return {
|
||||
SESSION_REPLY_TIMEOUT_METADATA_KEY: {
|
||||
"created_at_ms": 1,
|
||||
"message_id": "handle-message-1",
|
||||
"expect_reply": True,
|
||||
"timeout_seconds": 60,
|
||||
"source": {
|
||||
"name": "author",
|
||||
"session_key": "websocket:author",
|
||||
"handle_id": "handle_author",
|
||||
"color_slot": 2,
|
||||
},
|
||||
"target": {
|
||||
"name": "reviewer",
|
||||
"session_key": "websocket:reviewer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _save_session(
|
||||
sessions: SessionManager,
|
||||
key: str,
|
||||
*,
|
||||
workspace: Path,
|
||||
title: str,
|
||||
webui: bool,
|
||||
) -> None:
|
||||
session = sessions.get_or_create(key)
|
||||
session.metadata.update({
|
||||
"title": title,
|
||||
"webui": webui,
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(workspace.resolve()),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
})
|
||||
sessions.save(session, fsync=True)
|
||||
|
||||
|
||||
def _empty_send_tool(tmp_path: Path) -> SendSessionMessageTool:
|
||||
return SendSessionMessageTool(
|
||||
sessions=SessionManager(tmp_path / "state"),
|
||||
bus=MessageBus(),
|
||||
)
|
||||
assert tool.parameters["required"] == ["to", "content", "expect_reply"]
|
||||
timeout = tool.parameters["properties"]["reply_timeout_seconds"]
|
||||
assert (timeout["minimum"], timeout["maximum"]) == (5, 60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_uses_configured_per_minute_limit(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
for key in ("websocket:lead", "websocket:reviewer"):
|
||||
_save_session(sessions, key, workspace=workspace, title=key, webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many(["websocket:lead", "websocket:reviewer"])
|
||||
reviewer = handles["websocket:reviewer"]
|
||||
tool = SendSessionMessageTool.create(ToolContext(
|
||||
config=ToolsConfig(max_session_messages_per_minute=1),
|
||||
workspace=str(workspace),
|
||||
bus=MessageBus(),
|
||||
sessions=sessions,
|
||||
))
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="lead",
|
||||
session_key="websocket:lead",
|
||||
workspace=workspace,
|
||||
)):
|
||||
first = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="First",
|
||||
expect_reply=False,
|
||||
)
|
||||
second = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="Second",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert first == f"Sent to @{reviewer.name}."
|
||||
assert isinstance(second, ToolResult)
|
||||
assert second.is_error
|
||||
assert "1 per minute" in str(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_queues_user_input_for_target_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
for key in ("websocket:lead", "websocket:reviewer"):
|
||||
_save_session(sessions, key, workspace=workspace, title=key, webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
reviewer = directory.ensure_many(["websocket:lead", "websocket:reviewer"])[
|
||||
"websocket:reviewer"
|
||||
]
|
||||
bus = MessageBus()
|
||||
tool = SendSessionMessageTool(sessions=sessions, bus=bus, directory=directory)
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="lead",
|
||||
session_key="websocket:lead",
|
||||
turn_id="turn-1",
|
||||
workspace=workspace,
|
||||
metadata={"safe": "context"},
|
||||
)):
|
||||
result = await tool.execute(
|
||||
to=f"@{reviewer.name}",
|
||||
content="Review this",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert result == f"Sent to @{reviewer.name}."
|
||||
inbound = bus.inbound.get_nowait()
|
||||
assert inbound.session_key_override == "websocket:reviewer"
|
||||
assert inbound.content == "Review this"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_requires_a_session_context(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
|
||||
result = await tool.execute(
|
||||
to="@reviewer",
|
||||
content="Review this",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=60,
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.is_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_guides_a_requested_reply(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="author",
|
||||
metadata=_session_message_metadata(),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "Message from @reviewer. Reply with send_session_message."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_omits_unrequested_reply_guidance(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="author",
|
||||
metadata=_session_message_metadata(expect_reply=False),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "Message from @reviewer."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_message_guides_a_timed_out_reply(tmp_path: Path) -> None:
|
||||
tool = _empty_send_tool(tmp_path)
|
||||
provider = tool.runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="system",
|
||||
chat_id="websocket:author",
|
||||
metadata=_reply_timeout_metadata(),
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_collaboration"
|
||||
assert block.content == "No reply from @reviewer after 60s."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_runtime_context_identifies_self_and_verified_mentions(
|
||||
async def test_list_sessions_includes_all_persisted_channels_except_current(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "websocket:source"
|
||||
target_key = "websocket:handle"
|
||||
_save_session(sessions, source_key, workspace=project, title="Source", webui=True)
|
||||
_save_session(sessions, target_key, workspace=project, title="Session", webui=True)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
handles = directory.ensure_many([source_key, target_key])
|
||||
source = handles[source_key]
|
||||
handle = handles[target_key]
|
||||
provider = ListSessionsTool(sessions).runtime_context_provider()
|
||||
|
||||
block = await provider(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
metadata={
|
||||
"session_handles": [{
|
||||
**handle.public_payload(),
|
||||
"session_key": handle.session_key,
|
||||
}],
|
||||
},
|
||||
))
|
||||
|
||||
assert block is not None
|
||||
assert block.source == "session_handle"
|
||||
assert block.content == (
|
||||
f"Your handle: @{source.name}.\n"
|
||||
f"Mentioned sessions: @{handle.name}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_returns_all_session_handles_across_workspaces(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
other_project = tmp_path / "other"
|
||||
project.mkdir()
|
||||
other_project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "websocket:source"
|
||||
target_key = "websocket:handle"
|
||||
external_key = "telegram:external"
|
||||
other_key = "websocket:other"
|
||||
_save_session(
|
||||
sessions,
|
||||
source_key,
|
||||
workspace=project,
|
||||
title="The source title must stay private",
|
||||
webui=True,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
target_key,
|
||||
workspace=project,
|
||||
title="The handle title must stay private",
|
||||
webui=True,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
external_key,
|
||||
workspace=project,
|
||||
title="External conversation",
|
||||
webui=False,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
other_key,
|
||||
workspace=other_project,
|
||||
title="Other workspace",
|
||||
webui=True,
|
||||
)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:current", "telegram:other", "slack:team")
|
||||
tool = ListSessionsTool(sessions)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
chat_id="current",
|
||||
session_key="websocket:current",
|
||||
)):
|
||||
result = json.loads(await tool.execute())
|
||||
|
||||
handles = directory.ensure_many([target_key, source_key, external_key, other_key])
|
||||
handle = handles[target_key]
|
||||
source = handles[source_key]
|
||||
external = handles[external_key]
|
||||
other = handles[other_key]
|
||||
assert result == sorted([
|
||||
f"@{handle.name}",
|
||||
f"@{external.name}",
|
||||
f"@{other.name}",
|
||||
])
|
||||
assert f"@{source.name}" not in result
|
||||
encoded = json.dumps(result)
|
||||
assert "title" not in encoded
|
||||
assert "session_key" not in encoded
|
||||
assert str(project) not in encoded
|
||||
assert str(other_project) not in encoded
|
||||
assert tool.read_only is True
|
||||
assert set(result) == {
|
||||
f"@{session_handle_for_key('telegram:other').name}",
|
||||
f"@{session_handle_for_key('slack:team').name}",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_requires_trusted_turn_context(tmp_path: Path) -> None:
|
||||
tool = ListSessionsTool(SessionManager(tmp_path / "state"))
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.startswith("Error:")
|
||||
assert result.is_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_supports_non_webui_source_and_allocates_all_handles(
|
||||
async def test_send_publishes_user_input_to_the_existing_target(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "state")
|
||||
source_key = "telegram:source"
|
||||
target_key = "websocket:handle"
|
||||
_save_session(
|
||||
sessions,
|
||||
source_key,
|
||||
workspace=project,
|
||||
title="External source",
|
||||
webui=False,
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:source", "telegram:target")
|
||||
bus = MessageBus()
|
||||
tool = SendSessionMessageTool(sessions=sessions, bus=bus)
|
||||
target = session_handle_for_key("telegram:target")
|
||||
|
||||
sent_to = await tool.enqueue(
|
||||
source_session_key="websocket:source",
|
||||
target_handle=f"@{target.name}",
|
||||
content="Please review this.",
|
||||
expect_reply=False,
|
||||
)
|
||||
_save_session(
|
||||
sessions,
|
||||
target_key,
|
||||
workspace=project,
|
||||
title="WebUI handle",
|
||||
webui=True,
|
||||
inbound = await bus.consume_inbound()
|
||||
envelope = session_message_envelope(inbound.metadata)
|
||||
|
||||
assert sent_to == f"@{target.name}"
|
||||
assert inbound.channel == "system"
|
||||
assert inbound.chat_id == "telegram:target"
|
||||
assert inbound.session_key_override == "telegram:target"
|
||||
assert inbound.is_user_input
|
||||
assert inbound.content == "Please review this."
|
||||
assert envelope is not None
|
||||
assert envelope["source_session_key"] == "websocket:source"
|
||||
assert envelope["target_session_key"] == "telegram:target"
|
||||
assert inbound.metadata == {SESSION_MESSAGE_METADATA_KEY: envelope}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_fails_when_target_does_not_exist(tmp_path: Path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:source")
|
||||
bus = MessageBus()
|
||||
tool = SendSessionMessageTool(sessions=sessions, bus=bus)
|
||||
|
||||
with pytest.raises(SessionMessageError, match="was not found"):
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:source",
|
||||
target_handle="@missing-0000000000",
|
||||
content="Hello",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert bus.inbound.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_is_per_source_session_and_uses_a_rolling_minute(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:a", "websocket:b", "websocket:target")
|
||||
now = 0.0
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=MessageBus(),
|
||||
max_messages_per_minute=1,
|
||||
clock=lambda: now,
|
||||
)
|
||||
directory = SessionHandleDirectory(sessions)
|
||||
tool = ListSessionsTool(sessions)
|
||||
target = session_handle_for_key("websocket:target").name
|
||||
|
||||
request = RequestContext(
|
||||
channel="telegram",
|
||||
chat_id="source",
|
||||
session_key=source_key,
|
||||
workspace=project,
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="A1",
|
||||
expect_reply=False,
|
||||
)
|
||||
with request_context(request):
|
||||
result = await tool.execute()
|
||||
block = await tool.runtime_context_provider()(request)
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:b",
|
||||
target_handle=target,
|
||||
content="B1",
|
||||
expect_reply=False,
|
||||
)
|
||||
with pytest.raises(SessionMessageError, match="rate limit"):
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="A2",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
handles = directory.ensure_many([source_key, target_key])
|
||||
assert result == json.dumps([f"@{handles[target_key].name}"])
|
||||
assert block is not None
|
||||
assert block.content == f"Your handle: @{handles[source_key].name}."
|
||||
assert directory.store_path.exists()
|
||||
now = 61.0
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="A3",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
|
||||
def test_list_sessions_is_auto_discovered() -> None:
|
||||
discovered = ToolLoader().discover()
|
||||
assert ListSessionsTool in discovered
|
||||
assert SendSessionMessageTool in discovered
|
||||
assert not any(tool.__name__ == "ReplySessionTool" for tool in discovered)
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_timeout_injects_a_user_input_back_into_the_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:source", "websocket:target")
|
||||
bus = MessageBus()
|
||||
scheduler = _Scheduler()
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
schedule_later=scheduler,
|
||||
)
|
||||
target = session_handle_for_key("websocket:target")
|
||||
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:source",
|
||||
target_handle=target.name,
|
||||
content="Question",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=5,
|
||||
)
|
||||
await bus.consume_inbound()
|
||||
delay, timer = scheduler.calls[0]
|
||||
|
||||
assert delay == 5
|
||||
timer.fire()
|
||||
await asyncio.sleep(0)
|
||||
timeout = await bus.consume_inbound()
|
||||
assert timeout.chat_id == "websocket:source"
|
||||
assert timeout.is_user_input
|
||||
assert timeout.content == f"No reply from @{target.name} after 5 seconds."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reverse_message_cancels_the_pending_reply_timeout(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(sessions, "websocket:source", "websocket:target")
|
||||
bus = MessageBus()
|
||||
scheduler = _Scheduler()
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=bus,
|
||||
schedule_later=scheduler,
|
||||
)
|
||||
source = session_handle_for_key("websocket:source")
|
||||
target = session_handle_for_key("websocket:target")
|
||||
|
||||
await tool.enqueue(
|
||||
source_session_key=source.session_key,
|
||||
target_handle=target.name,
|
||||
content="Question",
|
||||
expect_reply=True,
|
||||
reply_timeout_seconds=5,
|
||||
)
|
||||
await tool.enqueue(
|
||||
source_session_key=target.session_key,
|
||||
target_handle=source.name,
|
||||
content="Answer",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert scheduler.calls[0][1].cancelled
|
||||
|
||||
Reference in New Issue
Block a user