267 lines
8.3 KiB
Python
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"},
|
|
]
|