Files
chainlit--chainlit/backend/tests/test_message.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:48:47 +08:00

758 lines
28 KiB
Python

import asyncio
import json
from contextlib import contextmanager
from unittest.mock import AsyncMock, Mock, patch
import pytest
from chainlit.action import Action
from chainlit.context import ChainlitContext, context_var
from chainlit.message import (
AskActionMessage,
AskElementMessage,
AskFileMessage,
AskUserMessage,
ErrorMessage,
Message,
MessageBase,
)
@contextmanager
def mock_chainlit_context(session=None):
"""Context manager to set up and tear down Chainlit context."""
mock_loop = Mock(spec=asyncio.AbstractEventLoop)
mock_session = session or Mock()
mock_session.thread_id = "thread_123"
with patch("asyncio.get_running_loop", return_value=mock_loop):
mock_emitter = AsyncMock()
mock_context = ChainlitContext(session=mock_session, emitter=mock_emitter)
token = context_var.set(mock_context)
try:
yield mock_context
finally:
context_var.reset(token)
class TestMessageBase:
"""Test suite for MessageBase class."""
def test_post_init_sets_thread_id(self):
"""Test that __post_init__ sets thread_id from session."""
with mock_chainlit_context():
msg = Message(content="test")
assert msg.thread_id == "thread_123"
def test_post_init_generates_id_if_not_provided(self):
"""Test that __post_init__ generates UUID if id not provided."""
with mock_chainlit_context():
msg = Message(content="test")
assert msg.id is not None
assert len(msg.id) == 36
def test_post_init_uses_provided_id(self):
"""Test that __post_init__ uses provided id."""
with mock_chainlit_context():
msg = Message(content="test", id="custom_id")
assert msg.id == "custom_id"
def test_from_dict_creates_message(self):
"""Test creating message from dictionary."""
step_dict = {
"id": "msg_123",
"parentId": "parent_123",
"createdAt": "2024-01-01T00:00:00Z",
"output": "Hello world",
"name": "Assistant",
"command": "/test",
"type": "user_message",
"language": "python",
"metadata": {"key": "value"},
}
with mock_chainlit_context():
msg = MessageBase.from_dict(step_dict)
assert msg.id == "msg_123"
assert msg.parent_id == "parent_123"
assert msg.created_at == "2024-01-01T00:00:00Z"
assert msg.content == "Hello world"
assert msg.author == "Assistant"
assert msg.command == "/test"
assert msg.type == "user_message"
assert msg.language == "python"
assert msg.metadata == {"key": "value"}
def test_from_dict_with_minimal_data(self):
"""Test from_dict with minimal required fields."""
step_dict = {
"id": "msg_123",
"createdAt": "2024-01-01T00:00:00Z",
"output": "Hello",
}
with mock_chainlit_context():
with patch("chainlit.message.config") as mock_config:
mock_config.ui.name = "DefaultBot"
msg = MessageBase.from_dict(step_dict)
assert msg.id == "msg_123"
assert msg.content == "Hello"
assert msg.author == "DefaultBot"
assert msg.type == "assistant_message"
def test_to_dict_returns_step_dict(self):
"""Test converting message to dictionary."""
with mock_chainlit_context():
msg = Message(
content="Test content",
author="TestBot",
language="python",
type="user_message",
metadata={"key": "value"},
tags=["tag1", "tag2"],
id="msg_123",
parent_id="parent_123",
command="/test",
)
msg.created_at = "2024-01-01T00:00:00Z"
result = msg.to_dict()
assert result["id"] == "msg_123"
assert result["threadId"] == "thread_123"
assert result["parentId"] == "parent_123"
assert result["createdAt"] == "2024-01-01T00:00:00Z"
assert result["command"] == "/test"
assert result["output"] == "Test content"
assert result["name"] == "TestBot"
assert result["type"] == "user_message"
assert result["language"] == "python"
assert result["streaming"] is False
assert result["isError"] is False
assert result["waitForAnswer"] is False
assert result["metadata"] == {"key": "value"}
assert result["tags"] == ["tag1", "tag2"]
@pytest.mark.asyncio
async def test_update_stops_streaming(self):
"""Test that update stops streaming."""
with mock_chainlit_context() as ctx:
msg = Message(content="test")
msg.streaming = True
with patch("chainlit.message.chat_context") as mock_chat_ctx:
with patch("chainlit.message.get_data_layer", return_value=None):
result = await msg.update()
assert msg.streaming is False
assert result is True
mock_chat_ctx.add.assert_called_once_with(msg)
ctx.emitter.update_step.assert_called_once()
@pytest.mark.asyncio
async def test_update_with_data_layer(self):
"""Test update with data layer."""
with mock_chainlit_context() as ctx:
msg = Message(content="test")
mock_data_layer = AsyncMock()
with patch("chainlit.message.chat_context"):
with patch(
"chainlit.message.get_data_layer", return_value=mock_data_layer
):
with patch("asyncio.create_task") as mock_create_task:
await msg.update()
mock_create_task.assert_called_once()
ctx.emitter.update_step.assert_called_once()
@pytest.mark.asyncio
async def test_remove_from_chat_context(self):
"""Test removing message from chat context."""
with mock_chainlit_context() as ctx:
msg = Message(content="test", id="msg_123")
with patch("chainlit.message.chat_context") as mock_chat_ctx:
with patch("chainlit.message.get_data_layer", return_value=None):
result = await msg.remove()
assert result is True
mock_chat_ctx.remove.assert_called_once_with(msg)
ctx.emitter.delete_step.assert_called_once()
class TestMessage:
"""Test suite for Message class."""
def test_message_with_string_content(self):
"""Test creating message with string content."""
with mock_chainlit_context():
msg = Message(content="Hello world")
assert msg.content == "Hello world"
assert msg.language is None
def test_message_with_dict_content(self):
"""Test creating message with dict content."""
with mock_chainlit_context():
content_dict = {"key": "value", "number": 42}
msg = Message(content=content_dict)
expected = json.dumps(content_dict, indent=4, ensure_ascii=False)
assert msg.content == expected
assert msg.language == "json"
def test_message_with_non_serializable_dict(self):
"""Test message with non-JSON-serializable dict."""
with mock_chainlit_context():
class NonSerializable:
pass
content_dict = {"obj": NonSerializable()}
msg = Message(content=content_dict)
assert msg.language == "text"
assert "NonSerializable" in msg.content
def test_message_with_non_string_content(self):
"""Test message with non-string, non-dict content."""
with mock_chainlit_context():
msg = Message(content=12345)
assert msg.content == "12345"
assert msg.language == "text"
def test_message_with_custom_author(self):
"""Test message with custom author."""
with mock_chainlit_context():
msg = Message(content="test", author="CustomBot")
assert msg.author == "CustomBot"
def test_message_with_default_author(self):
"""Test message uses default author from config."""
with mock_chainlit_context():
with patch("chainlit.message.config") as mock_config:
mock_config.ui.name = "DefaultBot"
msg = Message(content="test")
assert msg.author == "DefaultBot"
def test_message_with_actions(self):
"""Test message with actions."""
with mock_chainlit_context():
action1 = Mock(spec=Action)
action2 = Mock(spec=Action)
msg = Message(content="test", actions=[action1, action2])
assert len(msg.actions) == 2
assert action1 in msg.actions
assert action2 in msg.actions
def test_message_with_elements(self):
"""Test message with elements."""
with mock_chainlit_context():
element1 = Mock()
element2 = Mock()
msg = Message(content="test", elements=[element1, element2])
assert len(msg.elements) == 2
assert element1 in msg.elements
assert element2 in msg.elements
@pytest.mark.asyncio
async def test_message_send(self):
"""Test sending a message."""
with mock_chainlit_context() as ctx:
msg = Message(content="test")
with patch("chainlit.message.chat_context") as mock_chat_ctx:
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
result = await msg.send()
assert result == msg
assert msg.created_at is not None
assert msg.streaming is False
mock_chat_ctx.add.assert_called_once_with(msg)
ctx.emitter.send_step.assert_called_once()
@pytest.mark.asyncio
async def test_message_send_with_author_rename(self):
"""Test sending message with author rename."""
with mock_chainlit_context():
msg = Message(content="test", author="OldName")
async def rename_author(name):
return "NewName"
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = rename_author
await msg.send()
assert msg.author == "NewName"
@pytest.mark.asyncio
async def test_message_send_with_actions_and_elements(self):
"""Test sending message with actions and elements."""
with mock_chainlit_context():
action = AsyncMock(spec=Action)
element = AsyncMock()
msg = Message(content="test", actions=[action], elements=[element])
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
await msg.send()
action.send.assert_called_once()
element.send.assert_called_once()
@pytest.mark.asyncio
async def test_message_update_with_actions(self):
"""Test updating message with new actions."""
with mock_chainlit_context():
action1 = AsyncMock(spec=Action)
action1.forId = None
action2 = AsyncMock(spec=Action)
action2.forId = "existing_id"
msg = Message(content="test", actions=[action1, action2])
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
result = await msg.update()
assert result is True
action1.send.assert_called_once()
action2.send.assert_not_called()
@pytest.mark.asyncio
async def test_message_remove_actions(self):
"""Test removing all actions from message."""
with mock_chainlit_context():
action1 = AsyncMock(spec=Action)
action2 = AsyncMock(spec=Action)
msg = Message(content="test", actions=[action1, action2])
await msg.remove_actions()
action1.remove.assert_called_once()
action2.remove.assert_called_once()
@pytest.mark.asyncio
async def test_stream_token_starts_streaming(self):
"""Test that stream_token starts streaming."""
with mock_chainlit_context() as ctx:
msg = Message(content="")
await msg.stream_token("Hello")
assert msg.streaming is True
assert msg.content == "Hello"
ctx.emitter.stream_start.assert_called_once()
@pytest.mark.asyncio
async def test_stream_token_appends_content(self):
"""Test that stream_token appends to content."""
with mock_chainlit_context() as ctx:
msg = Message(content="Hello")
msg.streaming = True
await msg.stream_token(" world")
assert msg.content == "Hello world"
ctx.emitter.send_token.assert_called_once_with(
id=msg.id, token=" world", is_sequence=False
)
@pytest.mark.asyncio
async def test_stream_token_with_sequence(self):
"""Test stream_token with is_sequence=True."""
with mock_chainlit_context() as ctx:
msg = Message(content="Old content")
msg.streaming = True
await msg.stream_token("New content", is_sequence=True)
assert msg.content == "New content"
ctx.emitter.send_token.assert_called_once_with(
id=msg.id, token="New content", is_sequence=True
)
@pytest.mark.asyncio
async def test_stream_token_ignores_empty_token(self):
"""Test that empty tokens are ignored."""
with mock_chainlit_context() as ctx:
msg = Message(content="test")
await msg.stream_token("")
assert msg.content == "test"
ctx.emitter.stream_start.assert_not_called()
class TestErrorMessage:
"""Test suite for ErrorMessage class."""
def test_error_message_initialization(self):
"""Test ErrorMessage initialization."""
with mock_chainlit_context():
msg = ErrorMessage(content="An error occurred")
assert msg.content == "An error occurred"
assert msg.author is not None
assert msg.type == "assistant_message"
assert msg.is_error is True
assert msg.fail_on_persist_error is False
def test_error_message_with_custom_author(self):
"""Test ErrorMessage with custom author."""
with mock_chainlit_context():
msg = ErrorMessage(content="Error", author="ErrorBot")
assert msg.author == "ErrorBot"
def test_error_message_with_fail_on_persist(self):
"""Test ErrorMessage with fail_on_persist_error=True."""
with mock_chainlit_context():
msg = ErrorMessage(content="Error", fail_on_persist_error=True)
assert msg.fail_on_persist_error is True
@pytest.mark.asyncio
async def test_error_message_send(self):
"""Test sending error message."""
with mock_chainlit_context() as ctx:
msg = ErrorMessage(content="Error occurred")
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
result = await msg.send()
assert result == msg
ctx.emitter.send_step.assert_called_once()
class TestAskUserMessage:
"""Test suite for AskUserMessage class."""
def test_ask_user_message_initialization(self):
"""Test AskUserMessage initialization."""
with mock_chainlit_context():
msg = AskUserMessage(content="What is your name?")
assert msg.content == "What is your name?"
assert msg.author is not None
assert msg.timeout == 60
assert msg.raise_on_timeout is False
def test_ask_user_message_with_custom_timeout(self):
"""Test AskUserMessage with custom timeout."""
with mock_chainlit_context():
msg = AskUserMessage(content="Question?", timeout=120)
assert msg.timeout == 120
@pytest.mark.asyncio
async def test_ask_user_message_send(self):
"""Test sending AskUserMessage."""
with mock_chainlit_context() as ctx:
msg = AskUserMessage(content="Question?")
ctx.emitter.send_ask_user = AsyncMock(return_value={"output": "Answer"})
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
result = await msg.send()
assert result == {"output": "Answer"}
assert msg.wait_for_answer is False
ctx.emitter.send_ask_user.assert_called_once()
class TestAskFileMessage:
"""Test suite for AskFileMessage class."""
def test_ask_file_message_initialization(self):
"""Test AskFileMessage initialization."""
with mock_chainlit_context():
with patch("chainlit.message.config") as mock_config:
mock_config.ui.name = "Bot"
msg = AskFileMessage(
content="Upload a file", accept=["text/plain", "application/pdf"]
)
assert msg.content == "Upload a file"
assert msg.accept == ["text/plain", "application/pdf"]
assert msg.max_size_mb == 2
assert msg.max_files == 1
def test_ask_file_message_with_custom_limits(self):
"""Test AskFileMessage with custom limits."""
with mock_chainlit_context():
msg = AskFileMessage(
content="Upload", accept=["image/*"], max_size_mb=10, max_files=5
)
assert msg.max_size_mb == 10
assert msg.max_files == 5
@pytest.mark.asyncio
async def test_ask_file_message_send_with_response(self):
"""Test AskFileMessage send with file response."""
with mock_chainlit_context() as ctx:
msg = AskFileMessage(content="Upload", accept=["text/plain"])
file_response = [
{
"id": "file_123",
"name": "test.txt",
"path": "/path/to/test.txt",
"size": 1024,
"type": "text/plain",
}
]
ctx.emitter.send_ask_user = AsyncMock(return_value=file_response)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
result = await msg.send()
assert result is not None
assert len(result) == 1
assert result[0].id == "file_123"
assert result[0].name == "test.txt"
assert result[0].path == "/path/to/test.txt"
@pytest.mark.asyncio
async def test_ask_file_message_send_with_no_response(self):
"""Test AskFileMessage send with no response."""
with mock_chainlit_context() as ctx:
msg = AskFileMessage(content="Upload", accept=["text/plain"])
ctx.emitter.send_ask_user = AsyncMock(return_value=None)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
result = await msg.send()
assert result is None
class TestAskActionMessage:
"""Test suite for AskActionMessage class."""
def test_ask_action_message_initialization(self):
"""Test AskActionMessage initialization."""
with mock_chainlit_context():
with patch("chainlit.message.config") as mock_config:
mock_config.ui.name = "Bot"
action1 = Mock(spec=Action)
action2 = Mock(spec=Action)
msg = AskActionMessage(
content="Choose an action", actions=[action1, action2]
)
assert msg.content == "Choose an action"
assert len(msg.actions) == 2
@pytest.mark.asyncio
async def test_ask_action_message_send_with_response(self):
"""Test AskActionMessage send with action response."""
with mock_chainlit_context() as ctx:
action = AsyncMock(spec=Action)
action.id = "action_123"
msg = AskActionMessage(content="Choose", actions=[action])
ctx.emitter.send_ask_user = AsyncMock(
return_value={"id": "action_123", "label": "Confirm"}
)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
with patch("chainlit.message.chat_context"):
result = await msg.send()
assert result == {"id": "action_123", "label": "Confirm"}
assert msg.content == "**Selected:** Confirm"
action.send.assert_called_once()
action.remove.assert_called_once()
@pytest.mark.asyncio
async def test_ask_action_message_send_timeout(self):
"""Test AskActionMessage send with timeout."""
with mock_chainlit_context() as ctx:
action = AsyncMock(spec=Action)
action.id = "action_123"
msg = AskActionMessage(content="Choose", actions=[action])
ctx.emitter.send_ask_user = AsyncMock(return_value=None)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
with patch("chainlit.message.chat_context"):
result = await msg.send()
assert result is None
assert msg.content == "Timed out: no action was taken"
class TestAskElementMessage:
"""Test suite for AskElementMessage class."""
def test_ask_element_message_initialization(self):
"""Test AskElementMessage initialization."""
with mock_chainlit_context():
with patch("chainlit.message.config") as mock_config:
mock_config.ui.name = "Bot"
element = Mock()
msg = AskElementMessage(content="Submit form", element=element)
assert msg.content == "Submit form"
assert msg.element == element
@pytest.mark.asyncio
async def test_ask_element_message_send_submitted(self):
"""Test AskElementMessage send with submitted response."""
with mock_chainlit_context() as ctx:
element = AsyncMock()
element.id = "element_123"
msg = AskElementMessage(content="Submit", element=element)
ctx.emitter.send_ask_user = AsyncMock(
return_value={"submitted": True, "data": {"field": "value"}}
)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
with patch("chainlit.message.chat_context"):
result = await msg.send()
assert result == {"submitted": True, "data": {"field": "value"}}
assert msg.content == "Thanks for submitting"
element.send.assert_called_once()
element.remove.assert_called_once()
@pytest.mark.asyncio
async def test_ask_element_message_send_cancelled(self):
"""Test AskElementMessage send with cancelled response."""
with mock_chainlit_context() as ctx:
element = AsyncMock()
element.id = "element_123"
msg = AskElementMessage(content="Submit", element=element)
ctx.emitter.send_ask_user = AsyncMock(return_value={"submitted": False})
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
with patch("chainlit.message.chat_context"):
result = await msg.send()
assert result == {"submitted": False}
assert msg.content == "Cancelled"
@pytest.mark.asyncio
async def test_ask_element_message_send_timeout(self):
"""Test AskElementMessage send with timeout."""
with mock_chainlit_context() as ctx:
element = AsyncMock()
element.id = "element_123"
msg = AskElementMessage(content="Submit", element=element)
ctx.emitter.send_ask_user = AsyncMock(return_value=None)
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
with patch("chainlit.message.chat_context"):
result = await msg.send()
assert result is None
assert msg.content == "Timed out"
class TestMessageEdgeCases:
"""Test suite for message edge cases."""
def test_message_with_none_content(self):
"""Test message handles None content."""
with mock_chainlit_context():
msg = Message(content=None)
assert msg.content == "None"
def test_message_language_override(self):
"""Test that dict content sets language to json."""
with mock_chainlit_context():
msg = Message(content={"key": "value"}, language="python")
# Dict content always sets language to json, overriding the parameter
assert msg.language == "json"
@pytest.mark.asyncio
async def test_message_send_with_none_content(self):
"""Test sending message with None content."""
with mock_chainlit_context():
msg = Message(content="test")
msg.content = None
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
with patch("chainlit.message.config") as mock_config:
mock_config.code.author_rename = None
await msg.send()
assert msg.content == ""
@pytest.mark.asyncio
async def test_ask_message_remove_clears_ask(self):
"""Test that AskMessage remove clears ask state."""
with mock_chainlit_context() as ctx:
msg = AskUserMessage(content="Question?")
with patch("chainlit.message.chat_context"):
with patch("chainlit.message.get_data_layer", return_value=None):
await msg.remove()
ctx.emitter.clear.assert_called_once_with("clear_ask")
def test_message_metadata_and_tags(self):
"""Test message with metadata and tags."""
with mock_chainlit_context():
metadata = {"key1": "value1", "key2": 123}
tags = ["important", "user-query"]
msg = Message(content="test", metadata=metadata, tags=tags)
assert msg.metadata == metadata
assert msg.tags == tags
def test_message_to_dict_with_none_metadata(self):
"""Test to_dict with None metadata."""
with mock_chainlit_context():
msg = Message(content="test")
msg.metadata = None
result = msg.to_dict()
assert result["metadata"] == {}