Files
agent-desktop/tests/unit/agents/utils/test_message_processing.py
T

267 lines
8.3 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for message_processing utils.
Covers:
- is_first_user_interaction
- prepend_to_message_content
"""
# pylint: disable=redefined-outer-name
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentscope.message import DataBlock, Msg, TextBlock, URLSource
from pineagents.agents.utils.message_processing import (
_process_audio_block,
is_first_user_interaction,
prepend_to_message_content,
process_file_and_media_blocks_in_message,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _msg(role: str, content="content"):
m = MagicMock()
m.role = role
m.content = content
return m
def _audio_message(audio_path, media_type="audio/opus"):
block = DataBlock(
source=URLSource(
url=audio_path.resolve().as_uri(),
media_type=media_type,
),
)
return Msg(name="user", role="user", content=[block]), block
def _mock_transcription(result=None):
return patch(
"pineagents.agents.utils.audio_transcription.transcribe_audio",
new=AsyncMock(return_value=result),
)
@pytest.fixture
def _audio_config():
config = MagicMock()
config.agents.audio_mode = "auto"
config.agents.language = "en"
with patch(
"pineagents.agents.utils.message_processing.load_config",
return_value=config,
):
yield config
# ---------------------------------------------------------------------------
# is_first_user_interaction
# ---------------------------------------------------------------------------
class TestIsFirstUserInteraction:
"""P0: first user interaction detection."""
def test_empty_messages_returns_false(self):
assert is_first_user_interaction([]) is False
def test_single_user_no_assistant_is_first(self):
msgs = [_msg("user")]
assert is_first_user_interaction(msgs) is True
def test_user_with_assistant_is_not_first(self):
msgs = [_msg("user"), _msg("assistant")]
assert is_first_user_interaction(msgs) is False
def test_multiple_users_is_not_first(self):
msgs = [_msg("user"), _msg("user")]
assert is_first_user_interaction(msgs) is False
def test_system_then_user_is_first(self):
"""System messages before the user message are skipped."""
msgs = [_msg("system"), _msg("user")]
assert is_first_user_interaction(msgs) is True
def test_multiple_system_then_user_is_first(self):
msgs = [_msg("system"), _msg("system"), _msg("user")]
assert is_first_user_interaction(msgs) is True
def test_system_user_assistant_is_not_first(self):
msgs = [_msg("system"), _msg("user"), _msg("assistant")]
assert is_first_user_interaction(msgs) is False
def test_only_system_messages_returns_false(self):
msgs = [_msg("system"), _msg("system")]
assert is_first_user_interaction(msgs) is False
def test_only_assistant_returns_false(self):
msgs = [_msg("assistant")]
assert is_first_user_interaction(msgs) is False
# ---------------------------------------------------------------------------
# prepend_to_message_content
# ---------------------------------------------------------------------------
class TestPrependToMessageContent:
"""P0: guidance text is prepended to the message."""
def test_prepend_to_string_content(self):
msg = _msg("user", content="hello")
prepend_to_message_content(msg, "guidance")
assert msg.content == "guidance\n\nhello"
def test_prepend_to_string_content_empty_string(self):
msg = _msg("user", content="")
prepend_to_message_content(msg, "guidance")
assert msg.content == "guidance\n\n"
def test_prepend_to_list_with_text_block(self):
"""Prepends into the first text block dict."""
msg = _msg(
"user",
content=[
{"type": "text", "text": "original"},
],
)
prepend_to_message_content(msg, "guidance")
assert msg.content[0]["text"] == "guidance\n\noriginal"
def test_prepend_inserts_block_when_no_text_block(self):
"""No text block → inserts new block at start."""
msg = _msg(
"user",
content=[
{"type": "image", "url": "http://img"},
],
)
prepend_to_message_content(msg, "guidance")
first = msg.content[0]
assert getattr(first, "type", None) == "text"
assert getattr(first, "text", None) == "guidance"
def test_prepend_to_non_list_non_str_content_noop(self):
"""Non-string, non-list content is left untouched."""
msg = _msg("user", content=42)
prepend_to_message_content(msg, "guidance")
assert msg.content == 42
def test_prepend_modifies_first_text_block_only(self):
"""Only the first text block is modified."""
msg = _msg(
"user",
content=[
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
],
)
prepend_to_message_content(msg, "guidance")
assert msg.content[0]["text"] == "guidance\n\nfirst"
assert msg.content[1]["text"] == "second"
def test_prepend_preserves_other_blocks(self):
"""Non-text blocks after the text block are preserved."""
msg = _msg(
"user",
content=[
{"type": "text", "text": "text"},
{"type": "image", "url": "http://img"},
],
)
prepend_to_message_content(msg, "guidance")
assert len(msg.content) == 2
assert msg.content[1]["type"] == "image"
class TestProcessAudioDataBlock:
"""P0: local AgentScope audio blocks reach transcription."""
@pytest.mark.asyncio
async def test_local_audio_is_replaced_with_transcription(
self,
tmp_path,
_audio_config,
):
audio_path = tmp_path / "voice note.opus"
msg, _ = _audio_message(audio_path)
with _mock_transcription("hello from voice") as transcribe:
await process_file_and_media_blocks_in_message(msg)
transcribe.assert_awaited_once_with(str(audio_path.resolve()))
assert len(msg.content) == 1
assert isinstance(msg.content[0], TextBlock)
assert msg.content[0].text == "[Voice message]: hello from voice"
@pytest.mark.asyncio
async def test_failed_transcription_keeps_local_path_hint(
self,
tmp_path,
_audio_config,
):
audio_path = tmp_path / "voice.opus"
msg, _ = _audio_message(audio_path)
with _mock_transcription():
await process_file_and_media_blocks_in_message(msg)
assert len(msg.content) == 2
assert isinstance(msg.content[0], TextBlock)
assert msg.content[0].text == "[Voice message]: (audio file received)"
assert isinstance(msg.content[1], TextBlock)
assert str(audio_path.resolve()) in msg.content[1].text
@pytest.mark.asyncio
async def test_native_audio_remains_data_block(
self,
tmp_path,
_audio_config,
):
audio_path = tmp_path / "voice.wav"
msg, block = _audio_message(audio_path, "audio/wav")
_audio_config.agents.audio_mode = "native"
with _mock_transcription() as transcribe:
await process_file_and_media_blocks_in_message(msg)
transcribe.assert_not_awaited()
assert msg.content == [block]
assert block.source.media_type == "audio/wav"
@pytest.mark.asyncio
async def test_legacy_audio_replacement_remains_dict(
self,
tmp_path,
_audio_config,
):
audio_path = tmp_path / "voice.opus"
block = {
"type": "audio",
"source": {
"type": "url",
"url": audio_path.resolve().as_uri(),
"media_type": "audio/opus",
},
}
content = [block]
with _mock_transcription("legacy voice"):
handled = await _process_audio_block(
content,
0,
str(audio_path),
block,
)
assert handled is True
assert content == [
{"type": "text", "text": "[Voice message]: legacy voice"},
]