源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for message_processing utils.
|
||||
|
||||
Covers:
|
||||
- is_first_user_interaction
|
||||
- prepend_to_message_content
|
||||
- process_file_and_media_blocks_in_message
|
||||
"""
|
||||
# pylint: disable=redefined-outer-name
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agentscope.message import (
|
||||
Base64Source,
|
||||
DataBlock,
|
||||
Msg,
|
||||
TextBlock,
|
||||
URLSource,
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
from qwenpaw.agents.utils import message_processing
|
||||
from qwenpaw.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(
|
||||
"qwenpaw.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(
|
||||
"qwenpaw.agents.utils.message_processing.load_config",
|
||||
return_value=config,
|
||||
):
|
||||
yield config
|
||||
|
||||
|
||||
def _set_language(monkeypatch, language: str) -> None:
|
||||
config = MagicMock()
|
||||
config.agents.language = language
|
||||
monkeypatch.setattr(message_processing, "load_config", lambda: config)
|
||||
|
||||
|
||||
def _data_file_msg(local_path, name=None) -> Msg:
|
||||
return Msg(
|
||||
name="user",
|
||||
role="user",
|
||||
content=[
|
||||
DataBlock(
|
||||
source=URLSource(
|
||||
url=local_path.as_uri(),
|
||||
media_type="application/octet-stream",
|
||||
),
|
||||
name=name,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"},
|
||||
]
|
||||
|
||||
|
||||
class TestProcessLocalImageDataBlock:
|
||||
"""Local Console image blocks are frozen before entering context."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwritten_path_preserves_first_version(self, tmp_path):
|
||||
image_path = tmp_path / "upload.png"
|
||||
Image.new("RGB", (2, 2), color="red").save(image_path)
|
||||
first_block = DataBlock(
|
||||
source=URLSource(
|
||||
url=image_path.resolve().as_uri(),
|
||||
media_type="image/png",
|
||||
),
|
||||
)
|
||||
first_msg = Msg(name="user", role="user", content=[first_block])
|
||||
|
||||
await process_file_and_media_blocks_in_message(first_msg)
|
||||
first_source = first_msg.content[0].source
|
||||
|
||||
Image.new("RGB", (2, 2), color="blue").save(image_path)
|
||||
second_block = DataBlock(
|
||||
source=URLSource(
|
||||
url=image_path.resolve().as_uri(),
|
||||
media_type="image/png",
|
||||
),
|
||||
)
|
||||
second_msg = Msg(name="user", role="user", content=[second_block])
|
||||
await process_file_and_media_blocks_in_message(second_msg)
|
||||
|
||||
assert isinstance(first_source, Base64Source)
|
||||
assert isinstance(second_msg.content[0].source, Base64Source)
|
||||
assert first_msg.content[0].source.data == first_source.data
|
||||
assert second_msg.content[0].source.data != first_source.data
|
||||
|
||||
|
||||
class TestProcessFileAndMediaBlocks:
|
||||
"""Typed file blocks retain bounded, quoted display filenames."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_block_hint_preserves_chinese_filename(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
local_path = tmp_path / "884ff39f590c4472f__.docx"
|
||||
msg = _data_file_msg(local_path, "项目方案.docx")
|
||||
_set_language(monkeypatch, "zh")
|
||||
|
||||
await process_file_and_media_blocks_in_message(msg)
|
||||
|
||||
assert msg.content[1].text == (
|
||||
f'用户上传文件 "项目方案.docx",已经下载到 {local_path}'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_block_hint_normalizes_and_escapes_filename(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
local_path = tmp_path / "stored.docx"
|
||||
msg = _data_file_msg(
|
||||
local_path,
|
||||
"..\\..\\会议\n记\u0085录\u2028划\u2029.docx",
|
||||
)
|
||||
_set_language(monkeypatch, "en")
|
||||
|
||||
await process_file_and_media_blocks_in_message(msg)
|
||||
|
||||
assert msg.content[1].text == (
|
||||
"User uploaded a file "
|
||||
'"会议\\n记\\u0085录\\u2028划\\u2029.docx", '
|
||||
f"downloaded to {local_path}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_block_hint_bounds_display_filename(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
local_path = tmp_path / "stored.bin"
|
||||
msg = _data_file_msg(local_path, f"{'a' * 250}.txt")
|
||||
_set_language(monkeypatch, "en")
|
||||
|
||||
await process_file_and_media_blocks_in_message(msg)
|
||||
|
||||
expected_name = "a" * 200
|
||||
assert msg.content[1].text == (
|
||||
f'User uploaded a file "{expected_name}", '
|
||||
f"downloaded to {local_path}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_hint_without_name_keeps_existing_wording(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
local_path = tmp_path / "stored.bin"
|
||||
msg = _data_file_msg(local_path)
|
||||
_set_language(monkeypatch, "en")
|
||||
|
||||
await process_file_and_media_blocks_in_message(msg)
|
||||
|
||||
assert msg.content[1].text == (
|
||||
f"User uploaded a file, downloaded to {local_path}"
|
||||
)
|
||||
Reference in New Issue
Block a user