Files

330 lines
13 KiB
Python

"""Tests for TaskMessageQueue and InMemoryTaskMessageQueue."""
from datetime import datetime, timezone
import anyio
import pytest
from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage
from mcp.shared.experimental.tasks.resolver import Resolver
from mcp.types import JSONRPCNotification, JSONRPCRequest
@pytest.fixture
def queue() -> InMemoryTaskMessageQueue:
return InMemoryTaskMessageQueue()
def make_request(id: int = 1, method: str = "test/method") -> JSONRPCRequest:
return JSONRPCRequest(jsonrpc="2.0", id=id, method=method)
def make_notification(method: str = "test/notify") -> JSONRPCNotification:
return JSONRPCNotification(jsonrpc="2.0", method=method)
class TestInMemoryTaskMessageQueue:
@pytest.mark.anyio
async def test_enqueue_and_dequeue(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test basic enqueue and dequeue operations."""
task_id = "task-1"
msg = QueuedMessage(type="request", message=make_request())
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.type == "request"
assert result.message.method == "test/method"
@pytest.mark.anyio
async def test_dequeue_empty_returns_none(self, queue: InMemoryTaskMessageQueue) -> None:
"""Dequeue from empty queue returns None."""
result = await queue.dequeue("nonexistent-task")
assert result is None
@pytest.mark.anyio
async def test_fifo_ordering(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages are dequeued in FIFO order."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1, "first")))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2, "second")))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3, "third")))
msg1 = await queue.dequeue(task_id)
msg2 = await queue.dequeue(task_id)
msg3 = await queue.dequeue(task_id)
assert msg1 is not None and msg1.message.method == "first"
assert msg2 is not None and msg2.message.method == "second"
assert msg3 is not None and msg3.message.method == "third"
@pytest.mark.anyio
async def test_separate_queues_per_task(self, queue: InMemoryTaskMessageQueue) -> None:
"""Each task has its own queue."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1, "task1-msg")))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2, "task2-msg")))
msg1 = await queue.dequeue("task-1")
msg2 = await queue.dequeue("task-2")
assert msg1 is not None and msg1.message.method == "task1-msg"
assert msg2 is not None and msg2.message.method == "task2-msg"
@pytest.mark.anyio
async def test_peek_does_not_remove(self, queue: InMemoryTaskMessageQueue) -> None:
"""Peek returns message without removing it."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
peeked = await queue.peek(task_id)
dequeued = await queue.dequeue(task_id)
assert peeked is not None
assert dequeued is not None
assert isinstance(peeked.message, JSONRPCRequest)
assert isinstance(dequeued.message, JSONRPCRequest)
assert peeked.message.id == dequeued.message.id
@pytest.mark.anyio
async def test_is_empty(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test is_empty method."""
task_id = "task-1"
assert await queue.is_empty(task_id) is True
await queue.enqueue(task_id, QueuedMessage(type="notification", message=make_notification()))
assert await queue.is_empty(task_id) is False
await queue.dequeue(task_id)
assert await queue.is_empty(task_id) is True
@pytest.mark.anyio
async def test_clear_returns_all_messages(self, queue: InMemoryTaskMessageQueue) -> None:
"""Clear removes and returns all messages."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2)))
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3)))
messages = await queue.clear(task_id)
assert len(messages) == 3
assert await queue.is_empty(task_id) is True
@pytest.mark.anyio
async def test_clear_empty_queue(self, queue: InMemoryTaskMessageQueue) -> None:
"""Clear on empty queue returns empty list."""
messages = await queue.clear("nonexistent")
assert messages == []
@pytest.mark.anyio
async def test_notification_messages(self, queue: InMemoryTaskMessageQueue) -> None:
"""Test queuing notification messages."""
task_id = "task-1"
msg = QueuedMessage(type="notification", message=make_notification("log/message"))
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.type == "notification"
assert result.message.method == "log/message"
@pytest.mark.anyio
async def test_message_timestamp(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages have timestamps."""
before = datetime.now(timezone.utc)
msg = QueuedMessage(type="request", message=make_request())
after = datetime.now(timezone.utc)
assert before <= msg.timestamp <= after
@pytest.mark.anyio
async def test_message_with_resolver(self, queue: InMemoryTaskMessageQueue) -> None:
"""Messages can have resolvers."""
task_id = "task-1"
resolver: Resolver[dict[str, str]] = Resolver()
msg = QueuedMessage(
type="request",
message=make_request(),
resolver=resolver,
original_request_id=42,
)
await queue.enqueue(task_id, msg)
result = await queue.dequeue(task_id)
assert result is not None
assert result.resolver is resolver
assert result.original_request_id == 42
@pytest.mark.anyio
async def test_cleanup_specific_task(self, queue: InMemoryTaskMessageQueue) -> None:
"""Cleanup removes specific task's data."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2)))
queue.cleanup("task-1")
assert await queue.is_empty("task-1") is True
assert await queue.is_empty("task-2") is False
@pytest.mark.anyio
async def test_cleanup_all(self, queue: InMemoryTaskMessageQueue) -> None:
"""Cleanup without task_id removes all data."""
await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1)))
await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2)))
queue.cleanup()
assert await queue.is_empty("task-1") is True
assert await queue.is_empty("task-2") is True
@pytest.mark.anyio
async def test_wait_for_message_returns_immediately_if_message_exists(
self, queue: InMemoryTaskMessageQueue
) -> None:
"""wait_for_message returns immediately if queue not empty."""
task_id = "task-1"
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
# Should return immediately, not block
with anyio.fail_after(1):
await queue.wait_for_message(task_id)
@pytest.mark.anyio
async def test_wait_for_message_blocks_until_message(self, queue: InMemoryTaskMessageQueue) -> None:
"""wait_for_message blocks until a message is enqueued."""
task_id = "task-1"
received = False
waiter_started = anyio.Event()
async def enqueue_when_ready() -> None:
# Wait until the waiter has started before enqueueing
await waiter_started.wait()
await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request()))
async def wait_for_msg() -> None:
nonlocal received
# Signal that we're about to start waiting
waiter_started.set()
await queue.wait_for_message(task_id)
received = True
async with anyio.create_task_group() as tg:
tg.start_soon(wait_for_msg)
tg.start_soon(enqueue_when_ready)
assert received is True
@pytest.mark.anyio
async def test_notify_message_available_wakes_waiter(self, queue: InMemoryTaskMessageQueue) -> None:
"""notify_message_available wakes up waiting coroutines."""
task_id = "task-1"
notified = False
waiter_started = anyio.Event()
async def notify_when_ready() -> None:
# Wait until the waiter has started before notifying
await waiter_started.wait()
await queue.notify_message_available(task_id)
async def wait_for_notification() -> None:
nonlocal notified
# Signal that we're about to start waiting
waiter_started.set()
await queue.wait_for_message(task_id)
notified = True
async with anyio.create_task_group() as tg:
tg.start_soon(wait_for_notification)
tg.start_soon(notify_when_ready)
assert notified is True
@pytest.mark.anyio
async def test_peek_empty_queue_returns_none(self, queue: InMemoryTaskMessageQueue) -> None:
"""Peek on empty queue returns None."""
result = await queue.peek("nonexistent-task")
assert result is None
@pytest.mark.anyio
async def test_wait_for_message_double_check_race_condition(self, queue: InMemoryTaskMessageQueue) -> None:
"""wait_for_message returns early if message arrives after event creation but before wait."""
task_id = "task-1"
# To test the double-check path (lines 223-225), we need a message to arrive
# after the event is created (line 220) but before event.wait() (line 228).
# We simulate this by injecting a message before is_empty is called the second time.
original_is_empty = queue.is_empty
call_count = 0
async def is_empty_with_injection(tid: str) -> bool:
nonlocal call_count
call_count += 1
if call_count == 2 and tid == task_id:
# Before second check, inject a message - this simulates a message
# arriving between event creation and the double-check
queue._queues[task_id] = [QueuedMessage(type="request", message=make_request())]
return await original_is_empty(tid)
queue.is_empty = is_empty_with_injection # type: ignore[method-assign]
# Should return immediately due to double-check finding the message
with anyio.fail_after(1):
await queue.wait_for_message(task_id)
class TestResolver:
@pytest.mark.anyio
async def test_set_result_and_wait(self) -> None:
"""Test basic set_result and wait flow."""
resolver: Resolver[str] = Resolver()
resolver.set_result("hello")
result = await resolver.wait()
assert result == "hello"
assert resolver.done()
@pytest.mark.anyio
async def test_set_exception_and_wait(self) -> None:
"""Test set_exception raises on wait."""
resolver: Resolver[str] = Resolver()
resolver.set_exception(ValueError("test error"))
with pytest.raises(ValueError, match="test error"):
await resolver.wait()
assert resolver.done()
@pytest.mark.anyio
async def test_set_result_when_already_completed_raises(self) -> None:
"""Test that set_result raises if resolver already completed."""
resolver: Resolver[str] = Resolver()
resolver.set_result("first")
with pytest.raises(RuntimeError, match="already completed"):
resolver.set_result("second")
@pytest.mark.anyio
async def test_set_exception_when_already_completed_raises(self) -> None:
"""Test that set_exception raises if resolver already completed."""
resolver: Resolver[str] = Resolver()
resolver.set_result("done")
with pytest.raises(RuntimeError, match="already completed"):
resolver.set_exception(ValueError("too late"))
@pytest.mark.anyio
async def test_done_returns_false_before_completion(self) -> None:
"""Test done() returns False before any result is set."""
resolver: Resolver[str] = Resolver()
assert resolver.done() is False