b7f52be4c9
CI / Run CI (push) Has been cancelled
CI / check-backend (push) Has been cancelled
CI / check-frontend (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
766 lines
24 KiB
Python
766 lines
24 KiB
Python
import builtins
|
|
import json
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from chainlit.session import (
|
|
BaseSession,
|
|
HTTPSession,
|
|
JSONEncoderIgnoreNonSerializable,
|
|
McpSession,
|
|
WebsocketSession,
|
|
clean_metadata,
|
|
)
|
|
|
|
|
|
def make_exception_group(message: str, exceptions: list[BaseException]):
|
|
base_exception_group = getattr(builtins, "BaseExceptionGroup", None)
|
|
if base_exception_group is None:
|
|
pytest.skip("BaseExceptionGroup is unavailable on this Python version")
|
|
return base_exception_group(message, exceptions) # type: ignore[misc]
|
|
|
|
|
|
class TestJSONEncoderIgnoreNonSerializable:
|
|
"""Test suite for JSONEncoderIgnoreNonSerializable."""
|
|
|
|
def test_encoder_handles_serializable_objects(self):
|
|
"""Test that encoder handles normal serializable objects."""
|
|
data = {
|
|
"string": "value",
|
|
"number": 42,
|
|
"list": [1, 2, 3],
|
|
"dict": {"key": "value"},
|
|
}
|
|
result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)
|
|
assert json.loads(result) == data
|
|
|
|
def test_encoder_ignores_non_serializable_objects(self):
|
|
"""Test that encoder returns None for non-serializable objects."""
|
|
|
|
class NonSerializable:
|
|
pass
|
|
|
|
data = {"normal": "value", "non_serializable": NonSerializable()}
|
|
result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["normal"] == "value"
|
|
assert parsed["non_serializable"] is None
|
|
|
|
def test_encoder_with_nested_non_serializable(self):
|
|
"""Test encoder with nested non-serializable objects."""
|
|
|
|
class NonSerializable:
|
|
pass
|
|
|
|
data = {
|
|
"level1": {
|
|
"level2": {
|
|
"serializable": "value",
|
|
"non_serializable": NonSerializable(),
|
|
}
|
|
}
|
|
}
|
|
result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["level1"]["level2"]["serializable"] == "value"
|
|
assert parsed["level1"]["level2"]["non_serializable"] is None
|
|
|
|
|
|
class TestCleanMetadata:
|
|
"""Test suite for clean_metadata function."""
|
|
|
|
def test_clean_metadata_with_normal_data(self):
|
|
"""Test clean_metadata with normal serializable data."""
|
|
metadata = {"key": "value", "number": 42, "list": [1, 2, 3]}
|
|
result = clean_metadata(metadata)
|
|
assert result == metadata
|
|
|
|
def test_clean_metadata_removes_non_serializable(self):
|
|
"""Test that clean_metadata removes non-serializable objects."""
|
|
|
|
class NonSerializable:
|
|
pass
|
|
|
|
metadata = {"normal": "value", "non_serializable": NonSerializable()}
|
|
result = clean_metadata(metadata)
|
|
|
|
assert result["normal"] == "value"
|
|
assert result["non_serializable"] is None
|
|
|
|
def test_clean_metadata_redacts_large_data(self):
|
|
"""Test that clean_metadata redacts data exceeding max size."""
|
|
# Create large metadata
|
|
large_data = {"data": "x" * 2000000} # > 1MB
|
|
result = clean_metadata(large_data, max_size=1048576)
|
|
|
|
assert "message" in result
|
|
assert "exceeds the limit" in result["message"]
|
|
|
|
def test_clean_metadata_with_custom_max_size(self):
|
|
"""Test clean_metadata with custom max size."""
|
|
small_data = {"data": "x" * 100}
|
|
result = clean_metadata(small_data, max_size=50)
|
|
|
|
# Should be redacted because it exceeds 50 bytes
|
|
assert "message" in result
|
|
assert "exceeds the limit" in result["message"]
|
|
|
|
def test_clean_metadata_preserves_unicode(self):
|
|
"""Test that clean_metadata preserves Unicode characters."""
|
|
metadata = {"chinese": "你好", "emoji": "🎉", "japanese": "こんにちは"}
|
|
result = clean_metadata(metadata)
|
|
|
|
assert result["chinese"] == "你好"
|
|
assert result["emoji"] == "🎉"
|
|
assert result["japanese"] == "こんにちは"
|
|
|
|
|
|
class TestBaseSession:
|
|
"""Test suite for BaseSession class."""
|
|
|
|
def test_base_session_initialization(self):
|
|
"""Test BaseSession initialization with required parameters."""
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
assert session.id == "test_id"
|
|
assert session.client_type == "webapp"
|
|
assert session.thread_id is not None # Auto-generated UUID
|
|
assert session.user is None
|
|
assert session.token is None
|
|
assert session.user_env == {}
|
|
assert session.chat_settings == {}
|
|
|
|
def test_base_session_with_thread_id(self):
|
|
"""Test BaseSession with provided thread_id."""
|
|
thread_id = str(uuid.uuid4())
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=thread_id,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
assert session.thread_id == thread_id
|
|
assert session.thread_id_to_resume == thread_id
|
|
|
|
def test_base_session_with_user_env(self):
|
|
"""Test BaseSession with user environment variables."""
|
|
user_env = {"API_KEY": "secret", "ENV_VAR": "value"}
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=user_env,
|
|
)
|
|
|
|
assert session.user_env == user_env
|
|
|
|
def test_base_session_with_chat_profile(self):
|
|
"""Test BaseSession with chat profile."""
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
chat_profile="gpt-4",
|
|
)
|
|
|
|
assert session.chat_profile == "gpt-4"
|
|
|
|
def test_base_session_files_dir(self):
|
|
"""Test BaseSession files_dir property."""
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path("/tmp/files")):
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
assert session.files_dir == Path("/tmp/files/test_id")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_session_persist_file_with_content(self):
|
|
"""Test persisting a file with content."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
content = b"test file content"
|
|
result = await session.persist_file(
|
|
name="test.txt",
|
|
mime="text/plain",
|
|
content=content,
|
|
)
|
|
|
|
assert "id" in result
|
|
assert result["id"] in session.files
|
|
assert session.files[result["id"]]["name"] == "test.txt"
|
|
assert session.files[result["id"]]["type"] == "text/plain"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_session_persist_file_with_string_content(self):
|
|
"""Test persisting a file with string content."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
content = "test string content"
|
|
result = await session.persist_file(
|
|
name="test.txt",
|
|
mime="text/plain",
|
|
content=content,
|
|
)
|
|
|
|
assert "id" in result
|
|
file_id = result["id"]
|
|
assert session.files[file_id]["size"] > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_session_persist_file_without_path_or_content(self):
|
|
"""Test that persist_file raises error without path or content."""
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Either path or content must be provided"):
|
|
await session.persist_file(name="test.txt", mime="text/plain")
|
|
|
|
def test_base_session_to_persistable(self):
|
|
"""Test BaseSession to_persistable method."""
|
|
from chainlit.user_session import user_sessions
|
|
|
|
original_sessions = user_sessions.copy()
|
|
user_sessions.update({"test_id": {"key": "value"}})
|
|
|
|
try:
|
|
with patch("chainlit.config.config") as mock_config:
|
|
mock_config.project.persist_user_env = True
|
|
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env={"API_KEY": "secret"},
|
|
chat_profile="gpt-4",
|
|
)
|
|
session.chat_settings = {"temperature": 0.7}
|
|
|
|
result = session.to_persistable()
|
|
|
|
assert result["chat_settings"] == {"temperature": 0.7}
|
|
assert result["chat_profile"] == "gpt-4"
|
|
assert result["client_type"] == "webapp"
|
|
finally:
|
|
user_sessions.clear()
|
|
user_sessions.update(original_sessions)
|
|
|
|
def test_base_session_to_persistable_without_persist_user_env(self):
|
|
"""Test to_persistable removes user_env when persist_user_env is False."""
|
|
from chainlit.user_session import user_sessions
|
|
|
|
original_sessions = user_sessions.copy()
|
|
user_sessions.update({"test_id": {"env": {"KEY": "value"}}})
|
|
|
|
try:
|
|
with patch("chainlit.config.config") as mock_config:
|
|
mock_config.project.persist_user_env = False
|
|
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env={"API_KEY": "secret"},
|
|
)
|
|
|
|
result = session.to_persistable()
|
|
|
|
assert result["env"] == {}
|
|
finally:
|
|
user_sessions.clear()
|
|
user_sessions.update(original_sessions)
|
|
|
|
|
|
class TestHTTPSession:
|
|
"""Test suite for HTTPSession class."""
|
|
|
|
def test_http_session_initialization(self):
|
|
"""Test HTTPSession initialization."""
|
|
session = HTTPSession(
|
|
id="http_id",
|
|
client_type="copilot",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
assert session.id == "http_id"
|
|
assert session.client_type == "copilot"
|
|
assert isinstance(session, BaseSession)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_session_delete(self):
|
|
"""Test HTTPSession delete method."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = HTTPSession(
|
|
id="http_id",
|
|
client_type="copilot",
|
|
)
|
|
|
|
# Create files directory
|
|
session.files_dir.mkdir(exist_ok=True)
|
|
test_file = session.files_dir / "test.txt"
|
|
test_file.write_text("test")
|
|
|
|
assert session.files_dir.exists()
|
|
|
|
await session.delete()
|
|
|
|
assert not session.files_dir.exists()
|
|
|
|
|
|
class TestWebsocketSession:
|
|
"""Test suite for WebsocketSession class."""
|
|
|
|
def test_websocket_session_initialization(self):
|
|
"""Test WebsocketSession initialization."""
|
|
emit_mock = Mock()
|
|
emit_call_mock = Mock()
|
|
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=emit_mock,
|
|
emit_call=emit_call_mock,
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
assert session.id == "ws_id"
|
|
assert session.socket_id == "socket_123"
|
|
assert session.emit == emit_mock
|
|
assert session.emit_call == emit_call_mock
|
|
assert session.restored is False
|
|
assert session.mcp_sessions == {}
|
|
|
|
def test_websocket_session_language_detection(self):
|
|
"""Test WebsocketSession language detection from HTTP headers."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
environ={"HTTP_ACCEPT_LANGUAGE": "fr-FR,fr;q=0.9,en;q=0.8"},
|
|
)
|
|
|
|
assert session.language == "fr-FR"
|
|
|
|
def test_websocket_session_default_language(self):
|
|
"""Test WebsocketSession defaults to en-US without language header."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
environ={},
|
|
)
|
|
|
|
assert session.language == "en-US"
|
|
|
|
def test_websocket_session_restore(self):
|
|
"""Test WebsocketSession restore method."""
|
|
from chainlit.session import ws_sessions_sid
|
|
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="old_socket",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
assert ws_sessions_sid.get("old_socket") == session
|
|
|
|
session.restore("new_socket")
|
|
|
|
assert session.socket_id == "new_socket"
|
|
assert session.restored is True
|
|
assert ws_sessions_sid.get("old_socket") is None
|
|
assert ws_sessions_sid.get("new_socket") == session
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_session_delete(self):
|
|
"""Test WebsocketSession delete method."""
|
|
from chainlit.session import ws_sessions_id, ws_sessions_sid
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
# Create files directory
|
|
session.files_dir.mkdir(exist_ok=True)
|
|
|
|
assert ws_sessions_sid.get("socket_123") == session
|
|
assert ws_sessions_id.get("ws_id") == session
|
|
|
|
await session.delete()
|
|
|
|
assert not session.files_dir.exists()
|
|
assert ws_sessions_sid.get("socket_123") is None
|
|
assert ws_sessions_id.get("ws_id") is None
|
|
|
|
def test_websocket_session_get(self):
|
|
"""Test WebsocketSession.get class method."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
retrieved = WebsocketSession.get("socket_123")
|
|
assert retrieved == session
|
|
|
|
def test_websocket_session_get_by_id(self):
|
|
"""Test WebsocketSession.get_by_id class method."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
retrieved = WebsocketSession.get_by_id("ws_id")
|
|
assert retrieved == session
|
|
|
|
def test_websocket_session_require_success(self):
|
|
"""Test WebsocketSession.require with existing session."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
retrieved = WebsocketSession.require("socket_123")
|
|
assert retrieved == session
|
|
|
|
def test_websocket_session_require_failure(self):
|
|
"""Test WebsocketSession.require raises error for missing session."""
|
|
with pytest.raises(ValueError, match="Session not found"):
|
|
WebsocketSession.require("nonexistent_socket")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_session_flush_method_queue(self):
|
|
"""Test WebsocketSession flush_method_queue."""
|
|
from collections import deque
|
|
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
# Create a mock async method
|
|
mock_method = AsyncMock()
|
|
|
|
# Add items to queue
|
|
session.thread_queues["test_method"] = deque(
|
|
[
|
|
(mock_method, session, ("arg1",), {"kwarg1": "value1"}),
|
|
(mock_method, session, ("arg2",), {"kwarg2": "value2"}),
|
|
]
|
|
)
|
|
|
|
await session.flush_method_queue()
|
|
|
|
assert mock_method.call_count == 2
|
|
assert len(session.thread_queues["test_method"]) == 0
|
|
|
|
|
|
class TestSessionEdgeCases:
|
|
"""Test suite for session edge cases."""
|
|
|
|
def test_base_session_with_all_client_types(self):
|
|
"""Test BaseSession with different client types."""
|
|
client_types = ["webapp", "copilot", "teams", "slack", "discord"]
|
|
|
|
for client_type in client_types:
|
|
session = BaseSession(
|
|
id=f"test_{client_type}",
|
|
client_type=client_type,
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
assert session.client_type == client_type
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_file_with_mime_extension(self):
|
|
"""Test that persist_file adds correct file extension based on MIME type."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = BaseSession(
|
|
id="test_id",
|
|
client_type="webapp",
|
|
thread_id=None,
|
|
user=None,
|
|
token=None,
|
|
user_env=None,
|
|
)
|
|
|
|
# Test with image MIME type
|
|
result = await session.persist_file(
|
|
name="image.png",
|
|
mime="image/png",
|
|
content=b"fake image data",
|
|
)
|
|
|
|
file_id = result["id"]
|
|
file_path = session.files[file_id]["path"]
|
|
assert file_path.suffix == ".png"
|
|
|
|
def test_clean_metadata_with_empty_dict(self):
|
|
"""Test clean_metadata with empty dictionary."""
|
|
result = clean_metadata({})
|
|
assert result == {}
|
|
|
|
def test_websocket_session_with_chat_profile(self):
|
|
"""Test WebsocketSession with chat profile."""
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
chat_profile="gpt-4",
|
|
)
|
|
|
|
assert session.chat_profile == "gpt-4"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_session_delete_with_mcp_sessions(self):
|
|
"""Test WebsocketSession delete with MCP sessions."""
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
# Create a real McpSession with a completed task
|
|
import asyncio
|
|
|
|
stop = asyncio.Event()
|
|
stop.set() # already stopped
|
|
|
|
async def _noop():
|
|
pass
|
|
|
|
task = asyncio.create_task(_noop())
|
|
await task # let it finish
|
|
|
|
mcp = McpSession(
|
|
name="mcp1",
|
|
client=Mock(),
|
|
task=task,
|
|
stop_event=stop,
|
|
)
|
|
session.mcp_sessions["mcp1"] = mcp
|
|
|
|
await session.delete()
|
|
|
|
assert "mcp1" not in session.mcp_sessions
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_session_delete_with_hanging_mcp(self):
|
|
"""Test that session delete handles a slow MCP session gracefully."""
|
|
import asyncio
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)):
|
|
session = WebsocketSession(
|
|
id="ws_id",
|
|
socket_id="socket_123",
|
|
emit=Mock(),
|
|
emit_call=Mock(),
|
|
user_env={},
|
|
client_type="webapp",
|
|
)
|
|
|
|
stop = asyncio.Event()
|
|
|
|
async def _hang():
|
|
await stop.wait()
|
|
|
|
task = asyncio.create_task(_hang())
|
|
|
|
mcp = McpSession(
|
|
name="mcp1",
|
|
client=Mock(),
|
|
task=task,
|
|
stop_event=stop,
|
|
)
|
|
session.mcp_sessions["mcp1"] = mcp
|
|
|
|
# delete() should close the session cleanly
|
|
await session.delete()
|
|
|
|
assert task.done()
|
|
assert "mcp1" not in session.mcp_sessions
|
|
|
|
|
|
class TestMcpSession:
|
|
"""Test suite for the McpSession dataclass."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_signals_stop_and_awaits_task(self):
|
|
"""close() sets the stop event and waits for the task."""
|
|
import asyncio
|
|
|
|
stop = asyncio.Event()
|
|
|
|
async def _runner():
|
|
await stop.wait()
|
|
|
|
task = asyncio.create_task(_runner())
|
|
mcp = McpSession(
|
|
name="test",
|
|
client=Mock(),
|
|
task=task,
|
|
stop_event=stop,
|
|
)
|
|
|
|
await mcp.close()
|
|
|
|
assert stop.is_set()
|
|
assert task.done()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_cancels_on_timeout(self):
|
|
"""close() cancels a task that doesn't respond to stop_event."""
|
|
import asyncio
|
|
|
|
stop = asyncio.Event()
|
|
|
|
async def _stuck():
|
|
# Ignore stop_event entirely
|
|
await asyncio.sleep(3600)
|
|
|
|
task = asyncio.create_task(_stuck())
|
|
mcp = McpSession(
|
|
name="stuck",
|
|
client=Mock(),
|
|
task=task,
|
|
stop_event=stop,
|
|
)
|
|
|
|
# Temporarily reduce timeout for this test
|
|
import chainlit.session as session_mod
|
|
|
|
original_timeout = session_mod._CLOSE_TIMEOUT
|
|
session_mod._CLOSE_TIMEOUT = 0.1
|
|
try:
|
|
await mcp.close()
|
|
finally:
|
|
session_mod._CLOSE_TIMEOUT = original_timeout
|
|
|
|
assert task.done()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_idempotent(self):
|
|
"""Calling close() twice does not raise."""
|
|
import asyncio
|
|
|
|
stop = asyncio.Event()
|
|
|
|
async def _runner():
|
|
await stop.wait()
|
|
|
|
task = asyncio.create_task(_runner())
|
|
mcp = McpSession(
|
|
name="test",
|
|
client=Mock(),
|
|
task=task,
|
|
stop_event=stop,
|
|
)
|
|
|
|
await mcp.close()
|
|
await mcp.close() # second call should be safe
|
|
|
|
assert task.done()
|