mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 12:26:08 +00:00
Feat/test build (#2174)
* fix(ci): update unit-test workflow paths to match current source layout Replace stale pkg/** filter with src/langbot/** and add uv.lock. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(tests): update README to reflect current test layout - Fix stale paths: tests/pipeline → tests/unit_tests/pipeline - Update CI Python versions: 3.11, 3.12, 3.13 - Add test directory structure for box, config, platform, plugin, provider, storage - Document pytest markers and uv commands - Mention planned E2E tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add shared test factories package Create tests/factories/ with reusable test factories: - FakeApp: mock application with all dependencies - Message chains: text_chain, mention_chain, image_chain - Query factories: text_query, group_text_query, command_query, etc. No test changes - maintains backward compatibility. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add fake provider factory Add tests/factories/provider.py with: - FakeProvider: deterministic fake LLM provider - Error simulation: timeout, auth, rate-limit, malformed - Request capture for assertions - fake_model: mock model with attached provider Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add fake platform factory Add tests/factories/platform.py with: - FakePlatform: simulated platform adapter - Inbound message construction: friend/group/image - Mention-bot flag simulation - Outbound message capture for assertions - Streaming output support simulation - Send failure simulation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add comprehensive message/query factories Extend tests/factories/message.py with: - file_query: file attachment query - unsupported_query: unknown message segment - voice_query: audio/voice query - at_all_query: group @All mention - query_with_session: query with session object - query_with_config: query with custom pipeline config Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add fake message flow smoke test Create tests/smoke/test_fake_message_flow.py: - TestFakeMessageFlow: factory verification tests - TestMessageFlowIntegration: minimal flow smoke test - Tests FakeApp, FakeProvider, FakePlatform, query factories - Verifies LANGBOT_FAKE_PONG marker response - Captures outbound messages for assertions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add developer test-quick command Add scripts/test-quick.sh and Makefile with: - test-quick: runs ruff check + unit tests + smoke tests - No real provider keys or platform accounts required - Suitable for local branch self-test Update tests/README.md: - Document test-quick command - Document test factories package - Add smoke tests and factories directory structure Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): make test-quick reliable as developer gate Fixes for D-001验收问题: 1. test-quick.sh: use set -euo pipefail, uv run ruff, no tail pipe 2. Remove unused imports in factories (app.py, platform.py, provider.py) 3. Fix unused variable in smoke test 4. Add noqa: E402 to test_n8nsvapi.py lazy imports 5. Update smoke test docs: "minimal fake flow" not full pipeline Now test-quick is a reliable gate: lint failures exit 1, test failures propagate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(unit): add preproc and taskmgr unit tests U-001: Pipeline Preprocessor tests - Normal text message processing - Empty message handling - Image segment with/without vision model - Model selection and fallback - Variable extraction U-004: Core Task Manager tests (pattern-based) - Task creation and tracking patterns - Task cancellation patterns - Scope-based cancellation - Task type filtering - Pruning completed tasks - Wait all tasks Taskmgr tests use pattern-based approach to avoid circular import in source code (taskmgr → app → http_controller → migration → taskmgr). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(unit): add config loader unit tests U-005: Config Loader tests - Valid YAML config loading - Valid JSON config loading - Invalid YAML/JSON error behavior - Missing config file creation from template - Template completion for missing keys - ConfigManager load/dump operations - Exists check for both YAML and JSON All tests use tmp_path fixture, no real project config. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(unit): add chat and command handler pattern tests U-002: Chat Handler tests (pattern-based) - Normal message event emission pattern - prevent_default handling - User message alteration pattern - Runner selection pattern - Streaming/non-streaming response patterns - Exception handling modes (show-error, show-hint, hide) - Message history update pattern - Telemetry payload pattern U-003: Command Handler tests (pattern-based) - Command parsing and text extraction - Event creation pattern - Privilege/admin check pattern - Command result handling (text, error, image) - prevent_default handling - String truncation helper Uses pattern-based testing to avoid circular import issues in source code. Direct imports of handler modules trigger circular import chain. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: fix unused imports after ruff auto-fix Remove unused imports in test files: - test_config_loader.py: remove unused os - test_taskmgr.py: remove unused Mock - test_preproc.py: remove unused unsupported_query, image_chain Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(unit): improve taskmgr tests to test real classes U-004 improved: Tests now import and test actual classes: - TaskContext: new(), trace(), to_dict(), placeholder() - TaskWrapper: task creation, context, exception/result capture, cancel, to_dict - AsyncTaskManager: create_task, create_user_task, cancel_task, cancel_by_scope - Task pruning behavior Uses pre-mocking technique: - Mock langbot.pkg.core.app before import (breaks circular chain) - Mock langbot.pkg.core.entities with proper Enum All 24 tests now test real class behavior, not patterns. taskmgr.py coverage should improve significantly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(test): consolidate FakeApp and add sys.modules isolation utility - Extract tests/utils/import_isolation.py with isolated_sys_modules context manager - Extend tests/factories/app.py FakeApp with handler-specific attributes - Refactor test_chat_handler.py to use centralized FakeApp and cached imports - Refactor test_command_handler.py with mock_execute_factory fixture - Refactor test_smoke.py to move import-time sys.modules manipulation into fixture - Add SQLite migration integration tests (G-002) - Add HTTP API smoke integration tests (G-005) - Update CI workflow to call pytest for SQLite migrations (G-004) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add developer quality gate consolidation (G-007) - Add scripts/test-integration-fast.sh for fast integration tests - Add scripts/test-coverage.sh with 12% baseline threshold - Update Makefile with test-integration-fast, test-coverage, test-all-local - Update CI workflow with integration and coverage jobs - Add smoke marker to pytest.ini - Update tests/README.md with quality gate layers documentation - Add tests/integration/pipeline/ for pipeline stage-chain tests Quality gate layers: - Quick: ruff + unit + smoke (~2 min) - Fast Integration: SQLite/API/Pipeline (~3 min) - Coverage: 12% threshold gate (~8 min) - Full Local: all three combined Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): add PostgreSQL migration slow integration tests (G-003) - Add tests/integration/persistence/test_migrations_postgres.py - All tests marked with @pytest.mark.slow - Tests skip when TEST_POSTGRES_URL is not set (no local PostgreSQL) - Database isolation via clean_tables and clean_alembic_version fixtures - Update CI workflow to use pytest instead of inline Python script - Remove TODO(G-003) comment - Update tests/README.md with PostgreSQL test documentation Covered scenarios: - Baseline stamp sets revision - Upgrade from baseline to head - Upgrade idempotent - Get current on unstamped DB returns None Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(test): Phase 1.5 coverage expansion - COV-001 to COV-013 Coverage baseline raised from 13.65% to 26% (+12.35%) Gate raised from 12% to 18% Tasks completed: - COV-001: Command system unit tests (100% coverage) - COV-002: API service unit tests batch 1 (user/apikey/model/provider) - COV-003: Provider model manager unit tests - COV-004: Pipeline remaining stage tests (aggregator/cntfilter/longtext/msgtrun) - COV-005: Storage and utils coverage pass - COV-006: Gate ratchet 12%→15% - COV-007: Gate ratchet 15%→18% - COV-008: API service batch 2 (bot/pipeline/webhook/space/maintenance/mcp) - COV-009: Blocked - API controller circular import issue documented - COV-010: Plugin runtime unit tests (+0.08%) - COV-011: RAG and vector unit tests (+0.68%) - COV-012: Core boot and migration unit tests - COV-013: Provider requester logic unit tests (+0.62%) Key additions: - tests/utils/import_isolation.py: sys.modules isolation for circular imports - Provider requester mock tests: proved HTTP-dependent code can be tested locally - Vector filter utilities: 100% coverage on pure functions - API services: fake persistence pattern for unit testing Blocked issue COV-009 documented in langbot-test-plan/1.5/issues/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(phase1): add unit tests for telemetry, plugin, rag, persistence Add initial unit tests for Phase 1 of test coverage improvement: - telemetry: test initialization, payload sanitization, early returns (14.3% → 62.9%) - plugin: test _parse_plugin_id static method - rag: test _to_i18n_name static method - persistence: test serialize_model with datetime handling Overall core coverage: 41.9% → 42.2% Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(phase2): add unit tests for core, persistence, plugin, utils - Add test_handler_helpers.py for plugin handler helpers (7 tests) - Add test_mgr_methods.py for persistence manager (5 tests) - Add test_app_config_validation.py for core app config (12 tests) - Add test_knowledge_service.py for API knowledge service (22 tests) - Add test_kbmgr.py for RAG knowledge base manager (39 tests) - Add test_survey_manager.py for survey manager (22 tests) - Add test_connector_methods.py for plugin connector (24 tests) - Add test_funcschema.py for utils function schema (9 tests) - Add test_platform.py for utils platform detection (7 tests) - Add test_extract_deps.py for plugin deps extraction (7 tests) - Add test_database_decorator.py for persistence decorator (7 tests) - Add test_load_config.py for core config loading (19 tests) - Add COVERAGE_EXCLUSIONS.md documenting external adapter exclusions - Fix test_chat_session_limit.py path for portability Coverage: core 28% → 30%, persistence 24% → 24.4%, plugin 27% → 28% Total: 1082 tests passed, core module coverage 45.5% Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(integration): add API controller integration tests - Add test_pipelines.py (10 tests) covering pipelines CRUD operations - GET/POST/PUT/DELETE on /api/v1/pipelines - Extensions endpoint - Metadata endpoint - Coverage: pipelines controller 27% → 80% - Add test_providers.py (10 tests) covering provider/model management - Provider CRUD with model counts - LLM model CRUD - Coverage: providers controller 23% → 81%, models 29% → 45% Tests use Quart TestClient with mocked services for real HTTP behavior without external dependencies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(integration): add knowledge, bots, and model endpoints tests - Add test_knowledge.py (10 tests) covering knowledge base management - CRUD operations on /api/v1/knowledge/bases - Files management endpoints - Retrieve endpoint with validation - Coverage: knowledge/base.py 26% → 91% - Add test_bots.py (9 tests) covering bot management - CRUD operations on /api/v1/platform/bots - Logs endpoint - Send message endpoint with validation - Coverage: platform/bots.py 24% → 87% - Extend test_providers.py (+4 tests) for embedding/rerank models - Embedding models CRUD - Rerank models CRUD - Coverage: provider/models.py 29% → 60% Total integration tests: 53 (smoke 12 + pipelines 10 + providers 14 + knowledge 10 + bots 9) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(integration): add embed and monitoring endpoint tests Add integration tests for embed widget and monitoring API endpoints: - test_embed.py: 15 tests for widget.js, logo, turnstile, messages, reset, feedback - test_monitoring.py: 15 tests for overview, messages, llm-calls, sessions, errors, export Coverage improvements: - embed.py: 17% → 56% - monitoring.py: 17% → 93% Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(e2e): add minimal startup E2E tests Add E2E tests for LangBot startup flow: - tests/e2e/utils/config_factory.py: minimal config generation - tests/e2e/utils/process_manager.py: LangBot subprocess management - tests/e2e/conftest.py: E2E fixtures (session-scoped process) - tests/e2e/test_startup.py: 12 tests for startup verification Tests verify: - boot.py + stages execution - database initialization (SQLite) - API availability - migrations applied Uses embedded databases (SQLite, Chroma) - no external dependencies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(quality): fix fake tests and add missing coverage P0 fixes: - telemetry: rewrite fake tests with real behavior verification (25 tests) - config: delete copied-source tests, use proper imports (2 deleted) - persistence: fix try-except pass to verify specific errors P1 fixes: - pipeline: add real FixedWindowAlgo tests instead of mocks (12 tests) - provider: add SessionManager and ToolManager tests (25 tests) - storage: add S3StorageProvider tests with moto mock (16 tests) - plugin: add handler action tests for setting inheritance (15 tests) - rag: add file storage and ZIP processing tests (21 tests) - vector: add VDB filter conversion tests (30 tests) P2 fixes: - pipeline/msgtrun: strengthen assertions for exact message count - api: add response structure validation in integration tests New test files: - provider/test_session_manager.py - provider/test_tool_manager.py - storage/test_s3storage.py - plugin/test_handler_actions.py - rag/test_file_storage.py - vector/test_vdb_filter_conversion.py Source code bugs documented: - provider: TokenManager.next_token() ZeroDivisionError - telemetry: send_tasks class variable shared state - command: empty command IndexError, unused parameters - utils: funcschema KeyError - entity: vector.py independent declarative_base Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(test): update coverage stats and test structure - Update coverage from 22% to 30% - Add new test files to structure: - provider: session_manager, tool_manager - storage: s3storage - plugin: handler_actions - rag: file_storage - vector: vdb_filter_conversion - telemetry: rewritten tests - Update module coverage percentages Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: add 105 new unit tests for untested core functionality Add comprehensive tests for B-class issues (core functionality untested): Pipeline: - test_pool.py: QueryPool ID generation, caching, async context (12 tests) - test_ratelimit.py: Fixed timing-sensitive test tolerance - test_pipelinemgr.py: Use real Pydantic StageProcessResult instead of Mock Utils: - test_version.py: Version comparison functions (20 tests) - test_logcache.py: Log page management and retrieval (18 tests) - test_httpclient.py: HTTP session pool management (10 tests) - test_proxy.py: Proxy configuration from env and config (10 tests) - test_image.py: URL parsing and base64 extraction (12 tests) - test_pkgmgr.py: Pip command generation (8 tests) Discover: - test_engine.py: I18nString, Metadata, Component manifest (15 tests) Test count: 1193 → 1298 (+105 tests) Note: Some B-class issues cannot be tested due to circular import bugs filed as GitHub issues #2175 (pipeline) and #2176 (persistence). * test: tighten phase 1 coverage contracts * test: align ci integration isolation --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,637 @@
|
||||
"""
|
||||
MessageAggregator unit tests.
|
||||
Unit tests for MessageAggregator (aggregator) module.
|
||||
|
||||
Tests cover:
|
||||
- Message buffering and merging
|
||||
- Timer-based flush behavior
|
||||
- MAX_BUFFER_MESSAGES limit
|
||||
- Aggregation enabled/disabled
|
||||
- Config delay clamping
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_chain,
|
||||
friend_message_event,
|
||||
mock_adapter,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
|
||||
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(sample_message_event, mock_adapter):
|
||||
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
|
||||
aggregator = import_module('langbot.pkg.pipeline.aggregator')
|
||||
message_aggregator = aggregator.MessageAggregator(ap=None)
|
||||
def get_aggregator_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
return import_module('langbot.pkg.pipeline.aggregator')
|
||||
|
||||
first_message = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot-uuid',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=sample_message_event,
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='first')]),
|
||||
adapter=mock_adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=False,
|
||||
)
|
||||
second_message = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot-uuid',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=sample_message_event,
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='second')]),
|
||||
adapter=mock_adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=True,
|
||||
)
|
||||
|
||||
merged_message = message_aggregator._merge_messages([first_message, second_message])
|
||||
def make_aggregator_app():
|
||||
"""Create a FakeApp with necessary mocks for aggregator tests."""
|
||||
app = FakeApp()
|
||||
# Ensure query_pool has add_query method
|
||||
app.query_pool.add_query = AsyncMock()
|
||||
# Add pipeline_mgr mock
|
||||
app.pipeline_mgr = AsyncMock()
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
|
||||
return app
|
||||
|
||||
assert merged_message.routed_by_rule is True
|
||||
assert str(merged_message.message_chain) == 'first\nsecond'
|
||||
|
||||
class TestPendingMessage:
|
||||
"""Tests for PendingMessage dataclass."""
|
||||
|
||||
def test_pending_message_creation(self):
|
||||
"""PendingMessage should be created with correct fields."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline',
|
||||
)
|
||||
|
||||
assert pending.bot_uuid == 'test-bot'
|
||||
assert pending.launcher_type == provider_session.LauncherTypes.PERSON
|
||||
assert pending.message_chain == chain
|
||||
assert pending.timestamp is not None
|
||||
|
||||
|
||||
class TestSessionBuffer:
|
||||
"""Tests for SessionBuffer dataclass."""
|
||||
|
||||
def test_session_buffer_creation(self):
|
||||
"""SessionBuffer should be created with correct fields."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
buffer = aggregator.SessionBuffer(session_id='test-session')
|
||||
|
||||
assert buffer.session_id == 'test-session'
|
||||
assert buffer.messages == []
|
||||
assert buffer.timer_task is None
|
||||
assert buffer.last_message_time is not None
|
||||
|
||||
def test_session_buffer_with_messages(self):
|
||||
"""SessionBuffer should accept initial messages."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
buffer = aggregator.SessionBuffer(
|
||||
session_id='test-session',
|
||||
messages=[pending],
|
||||
)
|
||||
|
||||
assert len(buffer.messages) == 1
|
||||
|
||||
|
||||
class TestMessageAggregatorInit:
|
||||
"""Tests for MessageAggregator initialization."""
|
||||
|
||||
def test_aggregator_init(self):
|
||||
"""MessageAggregator should initialize with correct fields."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
assert agg.ap == app
|
||||
assert agg.buffers == {}
|
||||
assert isinstance(agg.lock, asyncio.Lock)
|
||||
|
||||
|
||||
class TestMessageAggregatorSessionId:
|
||||
"""Tests for session ID generation."""
|
||||
|
||||
def test_session_id_format(self):
|
||||
"""Session ID should be correctly formatted."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
session_id = agg._get_session_id(
|
||||
bot_uuid='bot-123',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=45678,
|
||||
)
|
||||
|
||||
assert session_id == 'bot-123:person:45678'
|
||||
|
||||
def test_session_id_different_launchers(self):
|
||||
"""Different launcher types should produce different IDs."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
person_id = agg._get_session_id(
|
||||
bot_uuid='bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=123,
|
||||
)
|
||||
|
||||
group_id = agg._get_session_id(
|
||||
bot_uuid='bot',
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=123,
|
||||
)
|
||||
|
||||
assert person_id != group_id
|
||||
|
||||
|
||||
class TestMessageAggregatorConfig:
|
||||
"""Tests for aggregation config retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_none_pipeline(self):
|
||||
"""None pipeline_uuid should return default config."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config(None)
|
||||
|
||||
assert enabled == False
|
||||
assert delay == 1.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_pipeline_not_found(self):
|
||||
"""Non-existent pipeline should return default config."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('unknown-pipeline')
|
||||
|
||||
assert enabled == False
|
||||
assert delay == 1.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_enabled(self):
|
||||
"""Pipeline with enabled aggregation should return True."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
|
||||
assert enabled == True
|
||||
assert delay == 2.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_delay_clamped_low(self):
|
||||
"""Delay below 1.0 should be clamped to 1.0."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 0.5, # Below minimum
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
|
||||
assert delay == 1.0 # Clamped to minimum
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_delay_clamped_high(self):
|
||||
"""Delay above 10.0 should be clamped to 10.0."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 15.0, # Above maximum
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
|
||||
assert delay == 10.0 # Clamped to maximum
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_delay_invalid_type(self):
|
||||
"""Invalid delay type should use default."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 'invalid', # Not a number
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
enabled, delay = await agg._get_aggregation_config('test-pipeline')
|
||||
|
||||
assert delay == 1.5 # Default
|
||||
|
||||
|
||||
class TestMessageAggregatorAddMessage:
|
||||
"""Tests for add_message behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_adds_to_query_pool(self):
|
||||
"""Disabled aggregation should directly add to query_pool."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
await agg.add_message(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None, # None -> disabled
|
||||
)
|
||||
|
||||
# Should have called query_pool.add_query
|
||||
assert app.query_pool.add_query.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_buffers_message(self):
|
||||
"""Enabled aggregation should buffer message."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
await agg.add_message(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline',
|
||||
)
|
||||
|
||||
# Should have buffered the message
|
||||
assert len(agg.buffers) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_buffer_flushes_immediately(self):
|
||||
"""Reaching MAX_BUFFER_MESSAGES should flush immediately."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {
|
||||
'trigger': {
|
||||
'message-aggregation': {
|
||||
'enabled': True,
|
||||
'delay': 10.0, # Long delay
|
||||
}
|
||||
}
|
||||
}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
# Add messages up to MAX_BUFFER_MESSAGES
|
||||
for i in range(aggregator.MAX_BUFFER_MESSAGES):
|
||||
await agg.add_message(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline',
|
||||
)
|
||||
|
||||
# Buffer should be flushed (empty or no buffer)
|
||||
session_id = agg._get_session_id('test-bot', provider_session.LauncherTypes.PERSON, 12345)
|
||||
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
|
||||
|
||||
|
||||
class TestMessageAggregatorMerge:
|
||||
"""Tests for message merging."""
|
||||
|
||||
def test_merge_single_message(self):
|
||||
"""Single message should return unchanged."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
merged = agg._merge_messages([pending])
|
||||
|
||||
assert merged.message_chain == chain
|
||||
|
||||
def test_merge_multiple_messages(self):
|
||||
"""Multiple messages should be merged with newline separator."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain1 = text_chain("hello")
|
||||
chain2 = text_chain("world")
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain1,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain2,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
merged = agg._merge_messages([pending1, pending2])
|
||||
|
||||
# Should contain both messages with separator
|
||||
merged_str = str(merged.message_chain)
|
||||
assert "hello" in merged_str
|
||||
assert "world" in merged_str
|
||||
|
||||
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
|
||||
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain1 = text_chain("first")
|
||||
chain2 = text_chain("second")
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain1,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=False,
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain2,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=True,
|
||||
)
|
||||
|
||||
merged = agg._merge_messages([pending1, pending2])
|
||||
|
||||
assert merged.routed_by_rule is True
|
||||
assert str(merged.message_chain) == 'first\nsecond'
|
||||
|
||||
|
||||
class TestMessageAggregatorFlush:
|
||||
"""Tests for buffer flush behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_empty_buffer(self):
|
||||
"""Flushing empty buffer should do nothing."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
await agg._flush_buffer('nonexistent-session')
|
||||
|
||||
# Should not call query_pool
|
||||
assert not app.query_pool.add_query.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_single_message(self):
|
||||
"""Flushing single message should add directly to query_pool."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
buffer = aggregator.SessionBuffer(
|
||||
session_id='test-session',
|
||||
messages=[pending],
|
||||
)
|
||||
|
||||
agg.buffers['test-session'] = buffer
|
||||
|
||||
await agg._flush_buffer('test-session')
|
||||
|
||||
assert app.query_pool.add_query.called
|
||||
assert 'test-session' not in agg.buffers
|
||||
|
||||
|
||||
class TestMessageAggregatorFlushAll:
|
||||
"""Tests for flush_all behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_all_empty(self):
|
||||
"""flush_all with no buffers should do nothing."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
await agg.flush_all()
|
||||
|
||||
# Should not call query_pool
|
||||
assert not app.query_pool.add_query.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_all_with_buffers(self):
|
||||
"""flush_all should flush all pending buffers."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain("hello")
|
||||
event = friend_message_event(chain)
|
||||
adapter = mock_adapter()
|
||||
|
||||
# Create two buffers
|
||||
pending1 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=67890,
|
||||
sender_id=67890,
|
||||
message_event=event,
|
||||
message_chain=chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=None,
|
||||
)
|
||||
|
||||
buffer1 = aggregator.SessionBuffer(session_id='session-1', messages=[pending1])
|
||||
buffer2 = aggregator.SessionBuffer(session_id='session-2', messages=[pending2])
|
||||
|
||||
agg.buffers['session-1'] = buffer1
|
||||
agg.buffers['session-2'] = buffer2
|
||||
|
||||
await agg.flush_all()
|
||||
|
||||
# Both buffers should be flushed
|
||||
assert len(agg.buffers) == 0
|
||||
assert app.query_pool.add_query.call_count == 2
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
Unit tests for ChatMessageHandler - REAL imports.
|
||||
|
||||
Tests the actual ChatMessageHandler class from production code.
|
||||
Uses tests.utils.import_isolation to break circular import chain safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from tests.factories import FakeApp
|
||||
|
||||
|
||||
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
"""
|
||||
Break circular import chain using isolated_sys_modules.
|
||||
|
||||
Chain: handler → core.app → pipeline.controller → http_controller → groups/plugins → taskmgr
|
||||
|
||||
Uses tests.utils.import_isolation for safe, reversible sys.modules manipulation.
|
||||
"""
|
||||
from tests.utils.import_isolation import (
|
||||
isolated_sys_modules,
|
||||
make_pipeline_handler_import_mocks,
|
||||
get_handler_modules_to_clear,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
mocks = make_pipeline_handler_import_mocks()
|
||||
|
||||
# Create a default runner that yields a simple response
|
||||
class DefaultRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
yield Message(role='assistant', content='fake response')
|
||||
|
||||
mocks['langbot.pkg.provider.runner'].preregistered_runners = [DefaultRunner]
|
||||
|
||||
clear = get_handler_modules_to_clear('chat')
|
||||
|
||||
with isolated_sys_modules(mocks=mocks, clear=clear):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_app():
|
||||
"""Create FakeApp instance."""
|
||||
return FakeApp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_ctx():
|
||||
"""Create mock event context."""
|
||||
ctx = Mock()
|
||||
ctx.is_prevented_default = Mock(return_value=False)
|
||||
ctx.event = Mock()
|
||||
ctx.event.user_message_alter = None
|
||||
ctx.event.reply_message_chain = None
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def set_runner():
|
||||
"""Factory fixture to set a custom runner for tests."""
|
||||
def _set_runner(runner_class):
|
||||
import sys
|
||||
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class]
|
||||
return _set_runner
|
||||
|
||||
|
||||
# ============== CACHED LAZY IMPORTS ==============
|
||||
|
||||
_chat_handler_module = None
|
||||
_entities_module = None
|
||||
|
||||
|
||||
def get_chat_handler():
|
||||
"""Import ChatMessageHandler after circular import chain is mocked."""
|
||||
global _chat_handler_module
|
||||
if _chat_handler_module is None:
|
||||
from importlib import import_module
|
||||
_chat_handler_module = import_module('langbot.pkg.pipeline.process.handlers.chat')
|
||||
return _chat_handler_module
|
||||
|
||||
|
||||
def get_entities():
|
||||
"""Import pipeline entities - uses real module."""
|
||||
global _entities_module
|
||||
if _entities_module is None:
|
||||
from importlib import import_module
|
||||
_entities_module = import_module('langbot.pkg.pipeline.entities')
|
||||
return _entities_module
|
||||
|
||||
|
||||
# ============== REAL ChatMessageHandler Tests ==============
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestChatMessageHandlerReal:
|
||||
"""Tests for real ChatMessageHandler class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_import_works(self):
|
||||
"""Verify we can import the real handler class."""
|
||||
chat = get_chat_handler()
|
||||
assert hasattr(chat, 'ChatMessageHandler')
|
||||
handler_cls = chat.ChatMessageHandler
|
||||
assert handler_cls.__name__ == 'ChatMessageHandler'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_creation(self, fake_app):
|
||||
"""ChatMessageHandler can be instantiated."""
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
assert handler.ap is fake_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prevent_default_without_reply_interrupts(self, fake_app, mock_event_ctx):
|
||||
"""prevent_default without reply chain yields INTERRUPT."""
|
||||
from tests.factories import text_query
|
||||
|
||||
chat = get_chat_handler()
|
||||
entities = get_entities()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = True
|
||||
mock_event_ctx.event.reply_message_chain = None
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
query = text_query('hello')
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prevent_default_with_reply_continues(self, fake_app, mock_event_ctx):
|
||||
"""prevent_default with reply yields CONTINUE and updates resp_messages."""
|
||||
from tests.factories import text_query, text_chain
|
||||
|
||||
chat = get_chat_handler()
|
||||
entities = get_entities()
|
||||
|
||||
reply_chain = text_chain('plugin reply')
|
||||
mock_event_ctx.is_prevented_default.return_value = True
|
||||
mock_event_ctx.event.reply_message_chain = reply_chain
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
query = text_query('hello')
|
||||
query.resp_messages = []
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
assert len(query.resp_messages) == 1
|
||||
assert query.resp_messages[0] == reply_chain
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""user_message_alter as string updates query.user_message."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
chat = get_chat_handler()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
mock_event_ctx.event.user_message_alter = 'altered text'
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('original')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
query.user_message = Message(role='user', content=[])
|
||||
|
||||
class QuickRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
yield Message(role='assistant', content='ok')
|
||||
|
||||
set_runner(QuickRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert query.user_message.content is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""Adapter without is_stream_output_supported defaults to non-stream."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
|
||||
|
||||
chat = get_chat_handler()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
mock_event_ctx.event.user_message_alter = None
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('test')
|
||||
query.adapter = Mock(spec=[])
|
||||
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
|
||||
|
||||
class SingleRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
yield Message(role='assistant', content='response')
|
||||
|
||||
set_runner(SingleRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) >= 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestChatHandlerStreaming:
|
||||
"""Tests for streaming behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""Streaming produces multiple results."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk
|
||||
|
||||
chat = get_chat_handler()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('stream test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
query.adapter.create_message_card = AsyncMock()
|
||||
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
|
||||
|
||||
class StreamRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
yield MessageChunk(role='assistant', content='Hello', is_final=False)
|
||||
yield MessageChunk(role='assistant', content=' World', is_final=True)
|
||||
|
||||
set_runner(StreamRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) >= 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestChatHandlerExceptions:
|
||||
"""Tests for exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_exception_yields_interrupt(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""Runner exception yields INTERRUPT with error notices."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
chat = get_chat_handler()
|
||||
entities = get_entities()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('fail test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
query.user_message = Message(role='user', content=[])
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
}
|
||||
|
||||
class FailingRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
raise ValueError('API error')
|
||||
yield
|
||||
|
||||
set_runner(FailingRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.INTERRUPT
|
||||
assert results[0].user_notice == 'Request failed.'
|
||||
assert results[0].error_notice is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_show_error_mode(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""show-error mode shows actual exception."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
chat = get_chat_handler()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('error test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
query.user_message = Message(role='user', content=[])
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'show-error'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
}
|
||||
|
||||
class ErrorRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
raise ValueError('Custom error')
|
||||
yield
|
||||
|
||||
set_runner(ErrorRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert results[0].user_notice == 'Custom error'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_hide_mode(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""hide mode shows no user notice."""
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
chat = get_chat_handler()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = False
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
query = text_query('hide test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
query.user_message = Message(role='user', content=[])
|
||||
|
||||
query.pipeline_config = {
|
||||
'output': {'misc': {'exception-handling': 'hide'}},
|
||||
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
|
||||
}
|
||||
|
||||
class HideErrorRunner:
|
||||
name = 'local-agent'
|
||||
def __init__(self, app, config):
|
||||
self.app = app
|
||||
self.config = config
|
||||
async def run(self, query):
|
||||
raise RuntimeError('hidden')
|
||||
yield
|
||||
|
||||
set_runner(HideErrorRunner)
|
||||
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert results[0].user_notice is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestChatHandlerHelper:
|
||||
"""Tests for helper methods."""
|
||||
|
||||
def test_cut_str_short(self, fake_app):
|
||||
"""cut_str returns short string unchanged."""
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
result = handler.cut_str('short text')
|
||||
assert result == 'short text'
|
||||
|
||||
def test_cut_str_long(self, fake_app):
|
||||
"""cut_str truncates long string."""
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
result = handler.cut_str('this is a very long string that exceeds twenty characters')
|
||||
assert '...' in result
|
||||
assert len(result) <= 23
|
||||
|
||||
def test_cut_str_multiline(self, fake_app):
|
||||
"""cut_str truncates multiline string."""
|
||||
chat = get_chat_handler()
|
||||
handler = chat.ChatMessageHandler(fake_app)
|
||||
result = handler.cut_str('first line\nsecond line')
|
||||
assert '...' in result
|
||||
@@ -91,7 +91,11 @@ async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(m
|
||||
|
||||
|
||||
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
|
||||
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
|
||||
# Use path relative to test file location for portability
|
||||
# test file: tests/unit_tests/pipeline/test_chat_session_limit.py
|
||||
# project root: 4 levels up
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
metadata_dir = project_root / 'src' / 'langbot' / 'templates' / 'metadata' / 'pipeline'
|
||||
|
||||
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
|
||||
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Unit tests for ContentFilterStage (cntfilter) pipeline stage.
|
||||
|
||||
Tests cover:
|
||||
- Pre-filter behavior (income message filtering)
|
||||
- Post-filter behavior (output message filtering)
|
||||
- Content ignore rules (prefix/regexp)
|
||||
- Pass/Block/Masked result handling
|
||||
- CONTINUE/INTERRUPT flow control
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
image_query,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
def get_cntfilter_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
# Import pipelinemgr first to trigger stage registration
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.cntfilter.cntfilter')
|
||||
|
||||
|
||||
def get_filter_module():
|
||||
"""Lazy import for filter base."""
|
||||
return import_module('langbot.pkg.pipeline.cntfilter.filter')
|
||||
|
||||
|
||||
def get_entities_module():
|
||||
"""Lazy import for pipeline entities."""
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def get_filter_entities_module():
|
||||
"""Lazy import for filter entities."""
|
||||
return import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||
|
||||
|
||||
def make_pipeline_config(**overrides):
|
||||
"""Create a pipeline config with defaults for content filter tests."""
|
||||
base_config = {
|
||||
'safety': {
|
||||
'content-filter': {
|
||||
'check-sensitive-words': False,
|
||||
'scope': 'both',
|
||||
}
|
||||
},
|
||||
'trigger': {
|
||||
'ignore-rules': {
|
||||
'prefix': [],
|
||||
'regexp': [],
|
||||
}
|
||||
},
|
||||
}
|
||||
# Deep merge for nested dicts
|
||||
for key, value in overrides.items():
|
||||
if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict):
|
||||
for sub_key, sub_value in value.items():
|
||||
if sub_key in base_config[key] and isinstance(base_config[key][sub_key], dict) and isinstance(sub_value, dict):
|
||||
base_config[key][sub_key].update(sub_value)
|
||||
else:
|
||||
base_config[key][sub_key] = sub_value
|
||||
else:
|
||||
base_config[key] = value
|
||||
return base_config
|
||||
|
||||
|
||||
class TestContentFilterStageInit:
|
||||
"""Tests for ContentFilterStage initialization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_basic_filters(self):
|
||||
"""Initialize should load required filters."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_sensitive_words(self):
|
||||
"""Initialize with sensitive words should load ban-word-filter."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
|
||||
app = FakeApp()
|
||||
# Mock sensitive_meta for ban-word-filter
|
||||
app.sensitive_meta = Mock()
|
||||
app.sensitive_meta.data = {
|
||||
'words': [],
|
||||
'mask': '*',
|
||||
'mask_word': '',
|
||||
}
|
||||
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
safety={
|
||||
'content-filter': {
|
||||
'check-sensitive-words': True,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert {filter_impl.name for filter_impl in stage.filter_chain} == {
|
||||
'ban-word-filter',
|
||||
'content-ignore',
|
||||
}
|
||||
|
||||
|
||||
class TestPreContentFilter:
|
||||
"""Tests for PreContentFilterStage (income message filtering)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_text_continues(self):
|
||||
"""Normal text message should continue pipeline."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello world")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_continues(self):
|
||||
"""Empty text message should continue pipeline."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Empty message chain
|
||||
query = text_query("")
|
||||
query.message_chain = platform_message.MessageChain([])
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Empty messages should continue
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_continues(self):
|
||||
"""Whitespace-only message should continue pipeline."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query(" ") # Only whitespace
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Whitespace-only should continue (stripped becomes empty)
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_text_component_continues(self):
|
||||
"""Message with non-text components should continue (skip filter)."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Image message (non-text)
|
||||
query = image_query()
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Non-text messages should continue (skip filter)
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_scope_skip_pre_filter(self):
|
||||
"""scope=output-msg should skip pre-filter."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
safety={
|
||||
'content-filter': {
|
||||
'scope': 'output-msg', # Only check output
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello world")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Should continue without filtering
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
|
||||
class TestContentIgnoreFilter:
|
||||
"""Tests for content-ignore filter rules."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_rule_blocks(self):
|
||||
"""Message matching prefix ignore rule should be blocked."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
trigger={
|
||||
'ignore-rules': {
|
||||
'prefix': ['/help', '/ping'],
|
||||
'regexp': [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("/help me")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Should be interrupted due to prefix rule
|
||||
assert result.result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regexp_rule_blocks(self):
|
||||
"""Message matching regexp ignore rule should be blocked."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
trigger={
|
||||
'ignore-rules': {
|
||||
'prefix': [],
|
||||
'regexp': ['^http://.*', r'\d{10}'],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("http://example.com")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Should be interrupted due to regexp rule
|
||||
assert result.result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_rule_match_continues(self):
|
||||
"""Message not matching any rule should continue."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
trigger={
|
||||
'ignore-rules': {
|
||||
'prefix': ['/help', '/ping'],
|
||||
'regexp': ['^http://.*'],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("normal message")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Should continue (no rule match)
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_rules_continues(self):
|
||||
"""Empty ignore rules should not block any message."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("/help me")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
# Should continue (empty rules)
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
|
||||
class TestPostContentFilter:
|
||||
"""Tests for PostContentFilterStage (output message filtering)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_response_continues(self):
|
||||
"""Normal response message should continue pipeline."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
# Add a response message
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='Hello back!')
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_income_scope_skip_post_filter(self):
|
||||
"""scope=income-msg should skip post-filter."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
safety={
|
||||
'content-filter': {
|
||||
'scope': 'income-msg', # Only check income
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='Response')
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
# Should continue without filtering
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_content_continues(self):
|
||||
"""Non-string content should continue (skip filter)."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
# Non-string content - use model_construct to bypass validation
|
||||
# The actual content type could be a list of ContentElement objects
|
||||
non_string_msg = provider_message.Message.model_construct(
|
||||
role='assistant',
|
||||
content=[Mock()], # Mock content element
|
||||
)
|
||||
query.resp_messages = [non_string_msg]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
# Should continue (skip filter for non-string)
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_continues(self):
|
||||
"""Empty response should continue pipeline."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
provider_message.Message(role='assistant', content='')
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'PostContentFilterStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
|
||||
class TestContentFilterStageInvalidName:
|
||||
"""Tests for invalid stage_inst_name handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_stage_name_raises(self):
|
||||
"""Unknown stage_inst_name should raise ValueError."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
with pytest.raises(ValueError, match='未知的 stage_inst_name'):
|
||||
await stage.process(query, 'UnknownStage')
|
||||
|
||||
|
||||
class TestContentIgnoreFilterDirect:
|
||||
"""Direct tests for ContentIgnore filter."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_ignore_pass(self):
|
||||
"""ContentIgnore should PASS for non-matching messages."""
|
||||
cntfilter = get_cntfilter_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
stage = cntfilter.ContentFilterStage(app)
|
||||
|
||||
pipeline_config = make_pipeline_config(
|
||||
trigger={
|
||||
'ignore-rules': {
|
||||
'prefix': ['/test'],
|
||||
'regexp': [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("normal message without prefix")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Unit tests for CommandHandler - REAL imports.
|
||||
|
||||
Tests the actual CommandHandler class from production code.
|
||||
Uses tests.utils.import_isolation to break circular import chain safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from tests.factories import FakeApp, command_query
|
||||
|
||||
|
||||
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
"""
|
||||
Break circular import chain using isolated_sys_modules.
|
||||
|
||||
Chain: handler → core.app → pipeline.controller → http_controller → groups/plugins → taskmgr
|
||||
|
||||
Uses tests.utils.import_isolation for safe, reversible sys.modules manipulation.
|
||||
"""
|
||||
from tests.utils.import_isolation import (
|
||||
isolated_sys_modules,
|
||||
make_pipeline_handler_import_mocks,
|
||||
get_handler_modules_to_clear,
|
||||
)
|
||||
|
||||
mocks = make_pipeline_handler_import_mocks()
|
||||
clear = get_handler_modules_to_clear('command')
|
||||
|
||||
with isolated_sys_modules(mocks=mocks, clear=clear):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_app():
|
||||
"""Create FakeApp instance."""
|
||||
return FakeApp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_ctx():
|
||||
"""Create mock event context."""
|
||||
ctx = Mock()
|
||||
ctx.is_prevented_default = Mock(return_value=False)
|
||||
ctx.event = Mock()
|
||||
ctx.event.reply_message_chain = None
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_execute_factory():
|
||||
"""Factory fixture to create mock cmd_mgr.execute generators."""
|
||||
def _create_execute(
|
||||
text: str | None = 'ok',
|
||||
error: str | None = None,
|
||||
image_url: str | None = None,
|
||||
image_base64: str | None = None,
|
||||
file_url: str | None = None,
|
||||
):
|
||||
async def mock_execute(command_text, full_command_text, query, session):
|
||||
ret = Mock()
|
||||
ret.text = text
|
||||
ret.error = error
|
||||
ret.image_url = image_url
|
||||
ret.image_base64 = image_base64
|
||||
ret.file_url = file_url
|
||||
yield ret
|
||||
return mock_execute
|
||||
return _create_execute
|
||||
|
||||
|
||||
# ============== CACHED LAZY IMPORTS ==============
|
||||
|
||||
_command_handler_module = None
|
||||
_entities_module = None
|
||||
|
||||
|
||||
def get_command_handler():
|
||||
"""Import CommandHandler after circular import chain is mocked."""
|
||||
global _command_handler_module
|
||||
if _command_handler_module is None:
|
||||
from importlib import import_module
|
||||
_command_handler_module = import_module('langbot.pkg.pipeline.process.handlers.command')
|
||||
return _command_handler_module
|
||||
|
||||
|
||||
def get_entities():
|
||||
"""Import pipeline entities - uses real module."""
|
||||
global _entities_module
|
||||
if _entities_module is None:
|
||||
from importlib import import_module
|
||||
_entities_module = import_module('langbot.pkg.pipeline.entities')
|
||||
return _entities_module
|
||||
|
||||
|
||||
# ============== REAL CommandHandler Tests ==============
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestCommandHandlerReal:
|
||||
"""Tests for real CommandHandler class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_import_works(self):
|
||||
"""Verify we can import the real handler class."""
|
||||
command = get_command_handler()
|
||||
assert hasattr(command, 'CommandHandler')
|
||||
handler_cls = command.CommandHandler
|
||||
assert handler_cls.__name__ == 'CommandHandler'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_creation(self, fake_app):
|
||||
"""CommandHandler can be instantiated."""
|
||||
command = get_command_handler()
|
||||
handler = command.CommandHandler(fake_app)
|
||||
assert handler.ap is fake_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_parsing_extracts_command_name(self, fake_app, mock_event_ctx):
|
||||
"""Command text is extracted after prefix."""
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
executed_commands = []
|
||||
async def track_execute(command_text, full_command_text, query, session):
|
||||
executed_commands.append(command_text)
|
||||
ret = Mock()
|
||||
ret.text = 'ok'
|
||||
ret.error = None
|
||||
ret.image_url = None
|
||||
ret.image_base64 = None
|
||||
ret.file_url = None
|
||||
yield ret
|
||||
|
||||
fake_app.cmd_mgr.execute = track_execute
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('help arg1 arg2')
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert executed_commands[0] == 'help arg1 arg2'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Admin users get privilege level 2."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Non-admin users get privilege level 1."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 67890
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prevent_default_with_reply_continues(self, fake_app, mock_event_ctx):
|
||||
"""prevent_default with reply yields CONTINUE."""
|
||||
from tests.factories.message import text_chain
|
||||
|
||||
command = get_command_handler()
|
||||
entities = get_entities()
|
||||
|
||||
reply_chain = text_chain('plugin reply')
|
||||
mock_event_ctx.is_prevented_default.return_value = True
|
||||
mock_event_ctx.event.reply_message_chain = reply_chain
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('test')
|
||||
query.resp_messages = []
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
assert len(query.resp_messages) == 1
|
||||
assert query.resp_messages[0] == reply_chain
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prevent_default_without_reply_interrupts(self, fake_app, mock_event_ctx):
|
||||
"""prevent_default without reply yields INTERRUPT."""
|
||||
command = get_command_handler()
|
||||
entities = get_entities()
|
||||
|
||||
mock_event_ctx.is_prevented_default.return_value = True
|
||||
mock_event_ctx.event.reply_message_chain = None
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('test')
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_type_person_command(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Person launcher creates PersonCommandSent event."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
from langbot_plugin.api.entities import events
|
||||
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('help')
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert isinstance(event, events.PersonCommandSent)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_type_group_command(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Group launcher creates GroupCommandSent event."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
from langbot_plugin.api.entities import events
|
||||
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('help')
|
||||
query.launcher_type = LauncherTypes.GROUP
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert isinstance(event, events.GroupCommandSent)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_result_text(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Text result is added to resp_messages."""
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory(text='Command output')
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('echo')
|
||||
query.resp_messages = []
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(query.resp_messages) == 1
|
||||
msg = query.resp_messages[0]
|
||||
assert msg.role == 'command'
|
||||
assert len(msg.content) == 1
|
||||
assert msg.content[0].type == 'text'
|
||||
assert msg.content[0].text == 'Command output'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_result_error(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Error result creates error message."""
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory(text=None, error='Command failed')
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('fail')
|
||||
query.resp_messages = []
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert len(query.resp_messages) == 1
|
||||
msg = query.resp_messages[0]
|
||||
assert msg.role == 'command'
|
||||
assert msg.content == 'Command failed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_result_image_url(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Image URL result is added to content."""
|
||||
command = get_command_handler()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory(
|
||||
text='Here is the image:',
|
||||
image_url='https://example.com/image.png'
|
||||
)
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('image')
|
||||
query.resp_messages = []
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
msg = query.resp_messages[0]
|
||||
assert len(msg.content) == 2
|
||||
assert msg.content[0].type == 'text'
|
||||
assert msg.content[1].type == 'image_url'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Empty result yields INTERRUPT."""
|
||||
command = get_command_handler()
|
||||
entities = get_entities()
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory(text=None)
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('empty')
|
||||
|
||||
results = []
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
assert results[0].result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestCommandHandlerHelper:
|
||||
"""Tests for helper methods."""
|
||||
|
||||
def test_cut_str_short(self, fake_app):
|
||||
"""cut_str returns short string unchanged."""
|
||||
command = get_command_handler()
|
||||
handler = command.CommandHandler(fake_app)
|
||||
result = handler.cut_str('short text')
|
||||
assert result == 'short text'
|
||||
|
||||
def test_cut_str_long(self, fake_app):
|
||||
"""cut_str truncates long string."""
|
||||
command = get_command_handler()
|
||||
handler = command.CommandHandler(fake_app)
|
||||
result = handler.cut_str('this is a very long string that exceeds twenty characters')
|
||||
assert '...' in result
|
||||
assert len(result) <= 23
|
||||
|
||||
def test_cut_str_multiline(self, fake_app):
|
||||
"""cut_str truncates multiline string."""
|
||||
command = get_command_handler()
|
||||
handler = command.CommandHandler(fake_app)
|
||||
result = handler.cut_str('first line\nsecond line')
|
||||
assert '...' in result
|
||||
@@ -1,39 +1,367 @@
|
||||
"""
|
||||
LongTextProcessStage unit tests
|
||||
Unit tests for LongTextProcessStage (longtext) pipeline stage.
|
||||
|
||||
Tests cover:
|
||||
- Strategy selection (none/image/forward)
|
||||
- Threshold boundary handling
|
||||
- Plain/non-Plain component handling
|
||||
- Strategy initialization and process
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from unittest.mock import AsyncMock
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
def get_modules():
|
||||
"""Lazy import to ensure proper initialization order"""
|
||||
longtext = import_module('langbot.pkg.pipeline.longtext.longtext')
|
||||
entities = import_module('langbot.pkg.pipeline.entities')
|
||||
return longtext, entities
|
||||
def get_longtext_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
# Import pipelinemgr first to trigger stage registration
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.longtext.longtext')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_message_chain_continues_without_processing(mock_app, sample_query):
|
||||
"""Empty response chains should be a no-op for long text processing."""
|
||||
longtext, entities = get_modules()
|
||||
def get_strategy_module():
|
||||
"""Lazy import for strategy base."""
|
||||
return import_module('langbot.pkg.pipeline.longtext.strategy')
|
||||
|
||||
sample_query.resp_message_chain = []
|
||||
sample_query.pipeline_config = {
|
||||
|
||||
def get_entities_module():
|
||||
"""Lazy import for pipeline entities."""
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def make_longtext_config(strategy: str = 'none', threshold: int = 1000):
|
||||
"""Create a pipeline config for long text processing."""
|
||||
return {
|
||||
'output': {
|
||||
'long-text-processing': {
|
||||
'threshold': 1,
|
||||
},
|
||||
},
|
||||
'strategy': strategy,
|
||||
'threshold': threshold,
|
||||
'font-path': '/nonexistent/font.ttf', # For image strategy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage = longtext.LongTextProcessStage(mock_app)
|
||||
stage.strategy_impl = AsyncMock()
|
||||
|
||||
result = await stage.process(sample_query, 'LongTextProcessStage')
|
||||
class TestLongTextProcessStageInit:
|
||||
"""Tests for LongTextProcessStage initialization."""
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query == sample_query
|
||||
stage.strategy_impl.process.assert_not_called()
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_none_strategy(self):
|
||||
"""Initialize with strategy='none' should set strategy_impl to None."""
|
||||
longtext = get_longtext_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='none')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert stage.strategy_impl is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_forward_strategy(self):
|
||||
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
|
||||
longtext = get_longtext_module()
|
||||
strategy = get_strategy_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert stage.strategy_impl is not None
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_unknown_strategy_raises(self):
|
||||
"""Initialize with unknown strategy should raise ValueError."""
|
||||
longtext = get_longtext_module()
|
||||
strategy = get_strategy_module()
|
||||
|
||||
# Save original preregistered_strategies
|
||||
original_strategies = strategy.preregistered_strategies.copy()
|
||||
|
||||
try:
|
||||
# Clear registered strategies to simulate unknown
|
||||
strategy.preregistered_strategies = []
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='unknown')
|
||||
|
||||
with pytest.raises(ValueError, match='Long message processing strategy not found'):
|
||||
await stage.initialize(pipeline_config)
|
||||
finally:
|
||||
# Restore original strategies
|
||||
strategy.preregistered_strategies = original_strategies
|
||||
|
||||
|
||||
class TestLongTextProcessStageProcess:
|
||||
"""Tests for LongTextProcessStage process behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_strategy_continues(self):
|
||||
"""strategy='none' should always continue."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='none')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="very long response")])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_continues_without_transform(self):
|
||||
"""Text shorter than threshold should not be transformed."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
# High threshold so text won't trigger transform
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=10000)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="short response")])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert len(result.new_query.resp_message_chain) == 1
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == 'short response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plain_component_skips(self):
|
||||
"""resp_message_chain with non-Plain components should skip processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=10) # Low threshold
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
# Non-Plain component (Image)
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([
|
||||
platform_message.Plain(text="short"),
|
||||
platform_message.Image(url="https://example.com/img.png")
|
||||
])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert [type(component) for component in components] == [
|
||||
platform_message.Plain,
|
||||
platform_message.Image,
|
||||
]
|
||||
assert components[0].text == 'short'
|
||||
assert components[1].url == 'https://example.com/img.png'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_resp_message_chain(self):
|
||||
"""Empty resp_message_chain should be handled gracefully."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_message_chain_does_not_call_strategy(self):
|
||||
"""Empty response chains should be a no-op for long text processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
stage.strategy_impl = AsyncMock()
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = make_longtext_config(strategy='forward', threshold=1)
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
stage.strategy_impl.process.assert_not_called()
|
||||
|
||||
class TestForwardStrategy:
|
||||
"""Tests for ForwardComponentStrategy."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_strategy_processes(self):
|
||||
"""ForwardComponentStrategy should create Forward component."""
|
||||
longtext = get_longtext_module()
|
||||
get_strategy_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
# Low threshold to trigger
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=10)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
# Create a mock adapter with bot_account_id
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.bot_account_id = '12345'
|
||||
query.adapter = mock_adapter
|
||||
|
||||
# Long text exceeding threshold
|
||||
long_text = "This is a very long response that exceeds the threshold"
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=long_text)])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_strategy_direct_process(self):
|
||||
"""Test ForwardComponentStrategy process method directly."""
|
||||
strategy = get_strategy_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Get ForwardComponentStrategy from preregistered
|
||||
for strat_cls in strategy.preregistered_strategies:
|
||||
if strat_cls.name == 'forward':
|
||||
strat = strat_cls(app)
|
||||
break
|
||||
else:
|
||||
pytest.skip('ForwardComponentStrategy not registered')
|
||||
|
||||
await strat.initialize()
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = make_longtext_config()
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.bot_account_id = '12345'
|
||||
query.adapter = mock_adapter
|
||||
|
||||
components = await strat.process("test message", query)
|
||||
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
|
||||
|
||||
class TestLongTextThreshold:
|
||||
"""Tests for threshold boundary handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_below_threshold_not_processed(self):
|
||||
"""Text below threshold should not be transformed."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
threshold = 100
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=threshold)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
|
||||
# Text below threshold
|
||||
short_text = "x" * (threshold - 1)
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=short_text)])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == short_text
|
||||
|
||||
|
||||
class TestLongTextProcessStageImageStrategy:
|
||||
"""Tests for image strategy handling (requires PIL/font)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_strategy_missing_font_fallback(self):
|
||||
"""Missing font should fallback to forward strategy."""
|
||||
longtext = get_longtext_module()
|
||||
strategy = get_strategy_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
# Use non-existent font path
|
||||
pipeline_config = make_longtext_config(strategy='image')
|
||||
|
||||
# On non-Windows without font, should fallback to forward
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Should have initialized (possibly with fallback strategy)
|
||||
if stage.strategy_impl is not None:
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Unit tests for ConversationMessageTruncator (msgtrun) pipeline stage.
|
||||
|
||||
Tests cover:
|
||||
- Normal truncation behavior based on max-round
|
||||
- Boundary length handling
|
||||
- Empty message handling
|
||||
- Multi-message chain truncation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
def get_msgtrun_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
# Import pipelinemgr first to trigger stage registration
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.msgtrun.msgtrun')
|
||||
|
||||
|
||||
def get_truncator_module():
|
||||
"""Lazy import for truncator base."""
|
||||
return import_module('langbot.pkg.pipeline.msgtrun.truncator')
|
||||
|
||||
|
||||
def get_entities_module():
|
||||
"""Lazy import for pipeline entities."""
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def get_round_truncator_module():
|
||||
"""Lazy import for round truncator."""
|
||||
return import_module('langbot.pkg.pipeline.msgtrun.truncators.round')
|
||||
|
||||
|
||||
def make_truncate_config(max_round: int = 5):
|
||||
"""Create a pipeline config with max-round setting."""
|
||||
return {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
'max-round': max_round,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestConversationMessageTruncatorInit:
|
||||
"""Tests for ConversationMessageTruncator initialization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_round_truncator(self):
|
||||
"""Initialize should select 'round' truncator by default."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
truncator = get_truncator_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert stage.trun is not None
|
||||
assert isinstance(stage.trun, truncator.Truncator)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_unknown_truncator_raises(self):
|
||||
"""Initialize with unknown truncator method should raise ValueError."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
truncator = get_truncator_module()
|
||||
|
||||
# Save original preregistered_truncators
|
||||
original_truncators = truncator.preregistered_truncators.copy()
|
||||
|
||||
try:
|
||||
# Clear registered truncators to simulate unknown method
|
||||
truncator.preregistered_truncators = []
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config()
|
||||
|
||||
with pytest.raises(ValueError, match='Unknown truncator'):
|
||||
await stage.initialize(pipeline_config)
|
||||
finally:
|
||||
# Restore original truncators
|
||||
truncator.preregistered_truncators = original_truncators
|
||||
|
||||
|
||||
class TestRoundTruncatorProcess:
|
||||
"""Tests for RoundTruncator truncation behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_within_limit(self):
|
||||
"""Messages within max-round limit should not be truncated."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config(max_round=5)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Create query with 3 messages (within limit)
|
||||
query = text_query("current message")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='message 1'),
|
||||
provider_message.Message(role='assistant', content='response 1'),
|
||||
provider_message.Message(role='user', content='message 2'),
|
||||
provider_message.Message(role='assistant', content='response 2'),
|
||||
provider_message.Message(role='user', content='current message'),
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# All messages should be preserved
|
||||
assert len(result.new_query.messages) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_exceeds_limit(self):
|
||||
"""Messages exceeding max-round should be truncated precisely.
|
||||
|
||||
Algorithm: traverse backwards, collect while current_round < max_round, count user messages as rounds.
|
||||
For max_round=2 with 7 messages (u1, a1, u2, a2, u3, a3, u_current):
|
||||
- Iterate: u_current(r=0<2, collect, r=1), a3(r=1<2, collect), u3(r=1<2, collect, r=2)
|
||||
- a2: r=2 not < 2 → break
|
||||
- Collected reverse: [u_current, a3, u3]
|
||||
- Reversed: [u3, a3, u_current] = 3 messages
|
||||
"""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config(max_round=2) # Only keep 2 rounds
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Create query with many messages exceeding limit
|
||||
# 7 messages = 3 full rounds + 1 current user
|
||||
query = text_query("current message")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='message 1'),
|
||||
provider_message.Message(role='assistant', content='response 1'),
|
||||
provider_message.Message(role='user', content='message 2'),
|
||||
provider_message.Message(role='assistant', content='response 2'),
|
||||
provider_message.Message(role='user', content='message 3'),
|
||||
provider_message.Message(role='assistant', content='response 3'),
|
||||
provider_message.Message(role='user', content='current message'),
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Should keep exactly 3 messages: message3, response3, current message
|
||||
messages = result.new_query.messages
|
||||
assert len(messages) == 3
|
||||
|
||||
# Verify exact message content
|
||||
assert messages[0].role == 'user'
|
||||
assert messages[0].content == 'message 3'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[1].content == 'response 3'
|
||||
assert messages[2].role == 'user'
|
||||
assert messages[2].content == 'current message'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_empty_messages(self):
|
||||
"""Empty messages list should return empty list."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = []
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert len(result.new_query.messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_single_message(self):
|
||||
"""Single message should be preserved."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='hello'),
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert len(result.new_query.messages) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_preserves_order(self):
|
||||
"""Truncation should preserve message order."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config(max_round=2)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("current")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='user1'),
|
||||
provider_message.Message(role='assistant', content='asst1'),
|
||||
provider_message.Message(role='user', content='user2'),
|
||||
provider_message.Message(role='assistant', content='asst2'),
|
||||
provider_message.Message(role='user', content='user3'),
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
messages = result.new_query.messages
|
||||
assert [(msg.role, msg.content) for msg in messages] == [
|
||||
('user', 'user2'),
|
||||
('assistant', 'asst2'),
|
||||
('user', 'user3'),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_max_round_one(self):
|
||||
"""max-round=1 should only keep last user message."""
|
||||
msgtrun = get_msgtrun_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = msgtrun.ConversationMessageTruncator(app)
|
||||
|
||||
pipeline_config = make_truncate_config(max_round=1)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("current")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='old1'),
|
||||
provider_message.Message(role='assistant', content='old1_resp'),
|
||||
provider_message.Message(role='user', content='current'),
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
messages = result.new_query.messages
|
||||
assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')]
|
||||
|
||||
|
||||
class TestRoundTruncatorDirect:
|
||||
"""Direct tests for RoundTruncator class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_truncator_direct_process(self):
|
||||
"""Test RoundTruncator truncate method directly."""
|
||||
truncator_mod = get_truncator_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Get the RoundTruncator class from preregistered
|
||||
for trun_cls in truncator_mod.preregistered_truncators:
|
||||
if trun_cls.name == 'round':
|
||||
trun = trun_cls(app)
|
||||
break
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = make_truncate_config(max_round=3)
|
||||
query.messages = [
|
||||
provider_message.Message(role='user', content='m1'),
|
||||
provider_message.Message(role='assistant', content='r1'),
|
||||
provider_message.Message(role='user', content='m2'),
|
||||
provider_message.Message(role='assistant', content='r2'),
|
||||
provider_message.Message(role='user', content='hello'),
|
||||
]
|
||||
|
||||
result = await trun.truncate(query)
|
||||
|
||||
assert result is not None
|
||||
assert hasattr(result, 'messages')
|
||||
@@ -19,13 +19,22 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
_mock_runner = MagicMock()
|
||||
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_mock_runner.RequestRunner = object
|
||||
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner)
|
||||
sys.modules.setdefault('langbot.pkg.core.app', MagicMock())
|
||||
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock())
|
||||
_mocked_imports = {
|
||||
'langbot.pkg.provider.runner': _mock_runner,
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
}
|
||||
_original_imports = {name: sys.modules.get(name) for name in _mocked_imports}
|
||||
sys.modules.update(_mocked_imports)
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
|
||||
import pytest # noqa: E402
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402
|
||||
|
||||
for _name, _original in _original_imports.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,10 +91,10 @@ async def test_stream_format_single_item():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) >= 1
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'hello'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'hello'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -100,9 +109,10 @@ async def test_stream_format_multi_item_accumulates():
|
||||
|
||||
chunks = await collect_chunks(runner, chunks_data)
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'foobar'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'foobar'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -115,9 +125,13 @@ async def test_stream_format_batches_every_8_items():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# At least the batch yield at chunk_idx==8 + final yield
|
||||
assert len(chunks) >= 2
|
||||
assert chunks[-1].is_final is True
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].is_final is False
|
||||
assert chunks[0].content == '01234567'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
assert chunks[1].is_final is True
|
||||
assert chunks[1].content == '01234567'
|
||||
assert chunks[1].msg_sequence == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -129,9 +143,9 @@ async def test_stream_format_split_across_network_chunks():
|
||||
|
||||
chunks = await collect_chunks(runner, [part1, part2])
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'world'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -143,10 +157,8 @@ async def test_stream_format_no_spurious_empty_yield():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# No chunk should have empty content before the real content arrives
|
||||
non_final = [c for c in chunks if not c.is_final]
|
||||
for c in non_final:
|
||||
assert c.content # must be non-empty
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].content == 'x'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -119,30 +119,24 @@ async def test_remove_pipeline(mock_app):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
"""Test runtime pipeline execution"""
|
||||
"""Test runtime pipeline execution with real Pydantic models."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
stage = get_stage_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
# Create mock stage that returns a simple result dict (avoiding Pydantic validation)
|
||||
mock_result = Mock()
|
||||
mock_result.result_type = Mock()
|
||||
mock_result.result_type.value = 'CONTINUE' # Simulate enum value
|
||||
mock_result.new_query = sample_query
|
||||
mock_result.user_notice = ''
|
||||
mock_result.console_notice = ''
|
||||
mock_result.debug_notice = ''
|
||||
mock_result.error_notice = ''
|
||||
|
||||
# Make it look like ResultType.CONTINUE
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
CONTINUE = MagicMock()
|
||||
CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison
|
||||
mock_result.result_type = CONTINUE
|
||||
# Create result using real Pydantic model (not Mock) to ensure validation
|
||||
real_result = entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=sample_query,
|
||||
user_notice='',
|
||||
console_notice='',
|
||||
debug_notice='',
|
||||
error_notice='',
|
||||
)
|
||||
|
||||
mock_stage = Mock(spec=stage.PipelineStage)
|
||||
mock_stage.process = AsyncMock(return_value=mock_result)
|
||||
mock_stage.process = AsyncMock(return_value=real_result)
|
||||
|
||||
# Create stage container
|
||||
stage_container = pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Unit tests for QueryPool.
|
||||
|
||||
Tests query management, ID generation, and async context handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestQueryPoolInit:
|
||||
"""Tests for QueryPool initialization."""
|
||||
|
||||
def test_init_creates_empty_pool(self):
|
||||
"""QueryPool initializes with empty lists."""
|
||||
pool = QueryPool()
|
||||
|
||||
assert pool.queries == []
|
||||
assert pool.cached_queries == {}
|
||||
assert pool.query_id_counter == 0
|
||||
assert pool.pool_lock is not None
|
||||
assert pool.condition is not None
|
||||
|
||||
def test_init_counter_starts_at_zero(self):
|
||||
"""Counter starts at zero."""
|
||||
pool = QueryPool()
|
||||
assert pool.query_id_counter == 0
|
||||
|
||||
|
||||
class TestQueryPoolAddQuery:
|
||||
"""Tests for add_query method."""
|
||||
|
||||
async def test_add_query_adds_query_with_id(self):
|
||||
"""add_query creates, stores, and caches a Query with the correct ID."""
|
||||
pool = QueryPool()
|
||||
|
||||
# Mock Query creation
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
mock_query.bot_uuid = 'test-bot-uuid'
|
||||
mock_query.launcher_id = 12345
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='test-bot-uuid',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
# Query is added to list and cache
|
||||
assert pool.queries[0] is mock_query
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
assert mock_query.query_id == 0
|
||||
|
||||
async def test_add_query_increments_counter(self):
|
||||
"""Each add_query increments the counter."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query1 = Mock()
|
||||
mock_query1.query_id = 0
|
||||
mock_query2 = Mock()
|
||||
mock_query2.query_id = 1
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.side_effect = [mock_query1, mock_query2]
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot2',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=2,
|
||||
sender_id=2,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert pool.query_id_counter == 2
|
||||
assert pool.queries[0].query_id == 0
|
||||
assert pool.queries[1].query_id == 1
|
||||
|
||||
async def test_add_query_appends_to_list(self):
|
||||
"""Query is appended to queries list."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert len(pool.queries) == 1
|
||||
assert pool.queries[0] is mock_query
|
||||
|
||||
async def test_add_query_caches_query(self):
|
||||
"""Query is cached by query_id."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert 0 in pool.cached_queries
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
|
||||
async def test_add_query_with_pipeline_uuid(self):
|
||||
"""Query can have pipeline_uuid set."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
mock_query.pipeline_uuid = 'test-pipeline-uuid'
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
)
|
||||
|
||||
# Verify pipeline_uuid was passed to Query constructor
|
||||
call_kwargs = MockQuery.call_args[1]
|
||||
assert call_kwargs['pipeline_uuid'] == 'test-pipeline-uuid'
|
||||
|
||||
async def test_add_query_sets_routed_by_rule_variable(self):
|
||||
"""Query has _routed_by_rule variable."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
mock_query.variables = {'_routed_by_rule': True}
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
routed_by_rule=True,
|
||||
)
|
||||
|
||||
# Verify variables includes _routed_by_rule
|
||||
call_kwargs = MockQuery.call_args[1]
|
||||
assert call_kwargs['variables']['_routed_by_rule'] is True
|
||||
|
||||
async def test_add_query_notifier_condition(self):
|
||||
"""add_query notifies waiting consumers."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
# Track if notify_all was called
|
||||
original_notify = pool.condition.notify_all
|
||||
notify_called = []
|
||||
|
||||
def mock_notify():
|
||||
notify_called.append(True)
|
||||
return original_notify()
|
||||
|
||||
pool.condition.notify_all = mock_notify
|
||||
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert len(notify_called) == 1
|
||||
|
||||
|
||||
class TestQueryPoolContext:
|
||||
"""Tests for async context manager."""
|
||||
|
||||
async def test_aenter_acquires_lock(self):
|
||||
"""__aenter__ acquires the pool lock."""
|
||||
pool = QueryPool()
|
||||
|
||||
async with pool as p:
|
||||
# Lock is acquired
|
||||
assert pool.pool_lock.locked()
|
||||
assert p is pool
|
||||
|
||||
async def test_aexit_releases_lock(self):
|
||||
"""__aexit__ releases the pool lock."""
|
||||
pool = QueryPool()
|
||||
|
||||
async with pool:
|
||||
pass
|
||||
|
||||
# Lock is released after context exit
|
||||
assert not pool.pool_lock.locked()
|
||||
|
||||
|
||||
class TestQueryPoolEdgeCases:
|
||||
"""Tests for edge cases."""
|
||||
|
||||
async def test_multiple_queries_cached_correctly(self):
|
||||
"""Multiple queries are cached separately."""
|
||||
pool = QueryPool()
|
||||
|
||||
mock_queries = []
|
||||
for i in range(5):
|
||||
q = Mock()
|
||||
q.query_id = i
|
||||
mock_queries.append(q)
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.side_effect = mock_queries
|
||||
|
||||
for i in range(5):
|
||||
await pool.add_query(
|
||||
bot_uuid=f'bot{i}',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=i,
|
||||
sender_id=i,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
# All cached
|
||||
assert len(pool.cached_queries) == 5
|
||||
|
||||
# Each query is cached by its ID
|
||||
for i in range(5):
|
||||
assert pool.cached_queries[i] is mock_queries[i]
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Unit tests for PreProcessor pipeline stage.
|
||||
|
||||
Tests cover preprocessing behavior including:
|
||||
- Normal text message processing
|
||||
- Empty message handling
|
||||
- Unsupported message segment handling
|
||||
- Image/file segment behavior
|
||||
- Model selection and fallback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
empty_query,
|
||||
image_query,
|
||||
group_text_query,
|
||||
)
|
||||
|
||||
|
||||
def get_preproc_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
return import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
|
||||
|
||||
def get_entities_module():
|
||||
"""Lazy import for pipeline entities."""
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
class TestPreProcessorNormalText:
|
||||
"""Tests for normal text message preprocessing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_text_continues(self):
|
||||
"""Normal text message should continue pipeline."""
|
||||
preproc = get_preproc_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
# Mock session manager to return a session
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
# Mock conversation
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock()
|
||||
mock_conversation.prompt.messages = []
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.update_time = Mock()
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
# Mock model manager
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock()
|
||||
mock_model.model_entity.uuid = 'test-model-uuid'
|
||||
mock_model.model_entity.abilities = ['func_call', 'vision']
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
|
||||
# Mock tool manager
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
# Mock plugin connector
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.default_prompt = []
|
||||
mock_event_ctx.event.prompt = []
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello world")
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_text_sets_user_message(self):
|
||||
"""PreProcessor should set user_message from text content."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("test message")
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.new_query.user_message is not None
|
||||
assert result.new_query.user_message.role == 'user'
|
||||
|
||||
|
||||
class TestPreProcessorEmptyMessage:
|
||||
"""Tests for empty message handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_message_continues(self):
|
||||
"""Empty message should follow expected behavior."""
|
||||
preproc = get_preproc_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = empty_query()
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
# Empty message should still continue with an empty provider content list.
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query.user_message is not None
|
||||
assert result.new_query.user_message.content == []
|
||||
|
||||
|
||||
class TestPreProcessorImageSegment:
|
||||
"""Tests for image segment handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_with_vision_model(self):
|
||||
"""Image should be included when model supports vision."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
# Model with vision support
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
# Image query with base64
|
||||
query = image_query(text="look at this", url=None)
|
||||
# Set base64 on the image component
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
chain = platform_message.MessageChain([
|
||||
platform_message.Plain(text="look at this"),
|
||||
platform_message.Image(base64="data:image/png;base64,abc123"),
|
||||
])
|
||||
query.message_chain = chain
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == preproc.entities.ResultType.CONTINUE
|
||||
# User message should have content
|
||||
assert result.new_query.user_message.content is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_without_vision_model(self):
|
||||
"""Image should be excluded when model doesn't support vision."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
# Model WITHOUT vision support
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = image_query(text="describe this")
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == preproc.entities.ResultType.CONTINUE
|
||||
|
||||
|
||||
class TestPreProcessorModelSelection:
|
||||
"""Tests for model selection and fallback behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_model_selected(self):
|
||||
"""Primary model UUID should be set in query."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello")
|
||||
|
||||
# Set pipeline config with primary model
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.new_query.use_llm_model_uuid == 'primary-model-uuid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_models_resolved(self):
|
||||
"""Fallback model UUIDs should be resolved and stored."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
# Primary model
|
||||
mock_primary = Mock()
|
||||
mock_primary.model_entity = Mock(uuid='primary-uuid', abilities=['func_call'])
|
||||
# Fallback model
|
||||
mock_fallback = Mock()
|
||||
mock_fallback.model_entity = Mock(uuid='fallback-uuid', abilities=['func_call'])
|
||||
|
||||
async def mock_get_model(uuid):
|
||||
if uuid == 'primary-uuid':
|
||||
return mock_primary
|
||||
elif uuid == 'fallback-uuid':
|
||||
return mock_fallback
|
||||
raise ValueError(f'Model {uuid} not found')
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello")
|
||||
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']},
|
||||
'prompt': 'default',
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert '_fallback_model_uuids' in result.new_query.variables
|
||||
assert 'fallback-uuid' in result.new_query.variables['_fallback_model_uuids']
|
||||
|
||||
|
||||
class TestPreProcessorVariables:
|
||||
"""Tests for query variable extraction."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_variables_set_from_query(self):
|
||||
"""PreProcessor should set variables from query context."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = 'conv-123'
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query("hello", sender_id=67890)
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
variables = result.new_query.variables
|
||||
assert 'launcher_type' in variables
|
||||
assert 'launcher_id' in variables
|
||||
assert 'sender_id' in variables
|
||||
assert variables['sender_id'] == 67890
|
||||
assert 'user_message_text' in variables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_variables_include_group_name(self):
|
||||
"""Group messages should include group_name variable."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='group')
|
||||
mock_session.launcher_id = 99999
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = group_text_query("hello", group_id=99999)
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
variables = result.new_query.variables
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
@@ -5,6 +5,8 @@ Tests the actual RateLimit implementation from pkg.pipeline.ratelimit
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from importlib import import_module
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
@@ -19,6 +21,285 @@ def get_modules():
|
||||
return ratelimit, entities, algo_module
|
||||
|
||||
|
||||
def get_fixedwin_module():
|
||||
"""Lazy import of FixedWindowAlgo"""
|
||||
return import_module('langbot.pkg.pipeline.ratelimit.algos.fixedwin')
|
||||
|
||||
|
||||
class TestFixedWindowAlgo:
|
||||
"""Tests for the actual FixedWindowAlgo implementation.
|
||||
|
||||
IMPORTANT: These tests verify the real algorithm logic, not mocks.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_for_algo(self):
|
||||
"""Create mock app for algorithm initialization."""
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
def sample_query_with_rate_limit(self, sample_query):
|
||||
"""Create query with rate limit configuration."""
|
||||
sample_query.pipeline_config = {
|
||||
'safety': {
|
||||
'rate-limit': {
|
||||
'window-length': 60, # 60 seconds window
|
||||
'limitation': 10, # 10 requests per window
|
||||
'strategy': 'drop',
|
||||
}
|
||||
}
|
||||
}
|
||||
return sample_query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_algo_initialization(self, mock_app_for_algo):
|
||||
"""Test that FixedWindowAlgo initializes correctly."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
assert algo.containers_lock is not None
|
||||
assert algo.containers == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_within_limit_returns_true(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
"""Test that requests within limit are allowed."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# Make requests within limit
|
||||
for i in range(10):
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
assert result is True, f"Request {i+1} should be allowed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_exceeds_limit_drop_strategy(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
"""Test that exceeding limit with 'drop' strategy returns False."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# Exhaust the limit
|
||||
for i in range(10):
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
|
||||
# Next request should be denied
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
|
||||
assert result is False, "Request exceeding limit should be denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_different_sessions_isolated(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
"""Test that different sessions have independent rate limits."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# Exhaust limit for session 1
|
||||
for i in range(10):
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'session1'
|
||||
)
|
||||
|
||||
# Session 2 should still have its own limit
|
||||
result = await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'session2'
|
||||
)
|
||||
|
||||
assert result is True, "Different session should have independent limit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_limit_one_request(self, mock_app_for_algo, sample_query):
|
||||
"""Test with limitation=1 allows only one request."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
sample_query.pipeline_config = {
|
||||
'safety': {
|
||||
'rate-limit': {
|
||||
'window-length': 60,
|
||||
'limitation': 1, # Only 1 request allowed
|
||||
'strategy': 'drop',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# First request allowed
|
||||
result1 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
assert result1 is True
|
||||
|
||||
# Second request denied
|
||||
result2 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
assert result2 is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_container_persists(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
"""Test that container is created and persists across requests."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# First request creates container
|
||||
await algo.require_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
|
||||
# Key format: 'LauncherTypes.PERSON_12345' (enum string representation)
|
||||
expected_key = 'LauncherTypes.PERSON_12345'
|
||||
assert expected_key in algo.containers
|
||||
container = algo.containers[expected_key]
|
||||
|
||||
# Container should have records
|
||||
assert len(container.records) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_new_window_clears_records(self, mock_app_for_algo, sample_query):
|
||||
"""Test that a new time window starts fresh records.
|
||||
|
||||
This test verifies the window calculation logic:
|
||||
- Records are keyed by window start timestamp
|
||||
- When window advances, new key is created
|
||||
"""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
# Use a very short window for testing
|
||||
sample_query.pipeline_config = {
|
||||
'safety': {
|
||||
'rate-limit': {
|
||||
'window-length': 1, # 1 second window for fast test
|
||||
'limitation': 5,
|
||||
'strategy': 'drop',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# Make requests in current window
|
||||
now = int(time.time())
|
||||
window_start = now - now % 1
|
||||
|
||||
for i in range(5):
|
||||
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
|
||||
|
||||
# Key format: 'LauncherTypes.PERSON_test'
|
||||
expected_key = 'LauncherTypes.PERSON_test'
|
||||
container = algo.containers[expected_key]
|
||||
assert window_start in container.records
|
||||
assert container.records[window_start] == 5
|
||||
|
||||
# Wait for next window (1 second)
|
||||
await asyncio.sleep(1.1)
|
||||
|
||||
# New request should be allowed (new window)
|
||||
result = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
|
||||
assert result is True, "New window should allow new requests"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_wait_strategy_blocks_until_next_window(self, mock_app_for_algo, sample_query):
|
||||
"""Test that 'wait' strategy blocks until next window.
|
||||
|
||||
NOTE: This test is timing-sensitive and may take ~1 second.
|
||||
"""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
# Use 1-second window for testability
|
||||
sample_query.pipeline_config = {
|
||||
'safety': {
|
||||
'rate-limit': {
|
||||
'window-length': 1,
|
||||
'limitation': 1, # Only 1 request per second
|
||||
'strategy': 'wait',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# First request allowed
|
||||
start_time = time.time()
|
||||
result1 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'wait_test'
|
||||
)
|
||||
assert result1 is True
|
||||
|
||||
# Exhaust limit
|
||||
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test')
|
||||
|
||||
# Third request should wait and then succeed
|
||||
result3 = await algo.require_access(
|
||||
sample_query,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'wait_test'
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
assert result3 is True, "After wait, request should succeed"
|
||||
# Should have waited approximately until next window
|
||||
# With 1-second window, elapsed should be > 0.5 second (allowing for timing variance)
|
||||
# Note: This is a timing-sensitive test, so we use a generous tolerance
|
||||
assert elapsed >= 0.5, f"Should have waited for next window, elapsed={elapsed:.2f}s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixedwin_release_access(self, mock_app_for_algo, sample_query_with_rate_limit):
|
||||
"""Test that release_access does nothing (current implementation)."""
|
||||
fixedwin = get_fixedwin_module()
|
||||
|
||||
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
|
||||
await algo.initialize()
|
||||
|
||||
# release_access is empty in current implementation
|
||||
await algo.release_access(
|
||||
sample_query_with_rate_limit,
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
'12345'
|
||||
)
|
||||
|
||||
# Should not raise or change state
|
||||
assert 'person_12345' not in algo.containers
|
||||
|
||||
|
||||
# Original mock-based tests for RateLimit stage integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_access_allowed(mock_app, sample_query):
|
||||
"""Test RequireRateLimitOccupancy allows access when rate limit is not exceeded"""
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Simple standalone tests to verify test infrastructure
|
||||
These tests don't import the actual pipeline code to avoid circular import issues
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
|
||||
|
||||
def test_pytest_works():
|
||||
"""Verify pytest is working"""
|
||||
assert True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_works():
|
||||
"""Verify async tests work"""
|
||||
mock = AsyncMock(return_value=42)
|
||||
result = await mock()
|
||||
assert result == 42
|
||||
|
||||
|
||||
def test_mocks_work():
|
||||
"""Verify mocking works"""
|
||||
mock = Mock()
|
||||
mock.return_value = 'test'
|
||||
assert mock() == 'test'
|
||||
|
||||
|
||||
def test_fixtures_work(mock_app):
|
||||
"""Verify fixtures are loaded"""
|
||||
assert mock_app is not None
|
||||
assert mock_app.logger is not None
|
||||
assert mock_app.sess_mgr is not None
|
||||
|
||||
|
||||
def test_sample_query(sample_query):
|
||||
"""Verify sample query fixture works"""
|
||||
assert sample_query.query_id == 'test-query-id'
|
||||
assert sample_query.launcher_id == 12345
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Unit tests for ResponseWrapper (wrapper) pipeline stage.
|
||||
|
||||
Tests cover:
|
||||
- MessageChain wrapping
|
||||
- Command response wrapping
|
||||
- Plugin response wrapping
|
||||
- Assistant response wrapping with content/tool_calls
|
||||
- Plugin event emission and INTERRUPT handling
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
text_query,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
|
||||
def get_wrapper_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
# Import pipelinemgr first to trigger stage registration
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.wrapper.wrapper')
|
||||
|
||||
|
||||
def get_entities_module():
|
||||
"""Lazy import for pipeline entities."""
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def make_wrapper_config():
|
||||
"""Create a pipeline config for wrapper tests."""
|
||||
return {
|
||||
'output': {
|
||||
'misc': {
|
||||
'at-sender': False,
|
||||
'quote-origin': False,
|
||||
'track-function-calls': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def make_session():
|
||||
"""Create a valid Session object for tests."""
|
||||
return provider_session.Session(
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
use_prompt_name="default",
|
||||
using_conversation=None,
|
||||
conversations=[],
|
||||
)
|
||||
|
||||
|
||||
class TestResponseWrapperInit:
|
||||
"""Tests for ResponseWrapper initialization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_passes(self):
|
||||
"""Initialize should complete without error."""
|
||||
wrapper = get_wrapper_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = {}
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
|
||||
class TestResponseWrapperMessageChain:
|
||||
"""Tests for MessageChain wrapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_chain_direct_append(self):
|
||||
"""MessageChain in resp_messages should be directly appended."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_messages = [
|
||||
platform_message.MessageChain([platform_message.Plain(text="response")])
|
||||
]
|
||||
query.resp_message_chain = []
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
assert len(results[0].new_query.resp_message_chain) == 1
|
||||
|
||||
|
||||
class TestResponseWrapperCommand:
|
||||
"""Tests for command response wrapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_response_prefix(self):
|
||||
"""Command response should have [bot] prefix."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create a command response message
|
||||
command_resp = Mock()
|
||||
command_resp.role = 'command'
|
||||
command_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Help info")])
|
||||
)
|
||||
query.resp_messages = [command_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
# Check that prefix was added (via get_content_platform_message_chain)
|
||||
command_resp.get_content_platform_message_chain.assert_called_once()
|
||||
|
||||
|
||||
class TestResponseWrapperPlugin:
|
||||
"""Tests for plugin response wrapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_response_direct(self):
|
||||
"""Plugin response should be wrapped without prefix."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create a plugin response message
|
||||
plugin_resp = Mock()
|
||||
plugin_resp.role = 'plugin'
|
||||
plugin_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Plugin response")])
|
||||
)
|
||||
query.resp_messages = [plugin_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
|
||||
|
||||
class TestResponseWrapperAssistant:
|
||||
"""Tests for assistant response wrapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_content_response(self):
|
||||
"""Assistant with content should emit event and wrap."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Mock session manager to return a valid Session
|
||||
session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector - normal event (not prevented)
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = None
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with content
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello back!"
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello back!")])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
# Event should have been emitted
|
||||
app.plugin_connector.emit_event.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_empty_content(self):
|
||||
"""Assistant with empty content should not emit event."""
|
||||
wrapper = get_wrapper_module()
|
||||
|
||||
app = FakeApp()
|
||||
app.plugin_connector.emit_event = AsyncMock()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with empty content
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = None
|
||||
assistant_resp.tool_calls = None
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert results == []
|
||||
assert query.resp_message_chain == []
|
||||
app.plugin_connector.emit_event.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_tool_calls(self):
|
||||
"""Assistant with tool_calls should show function call message."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Mock session manager to return a valid Session
|
||||
session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = None
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
pipeline_config['output']['misc']['track-function-calls'] = True
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with tool_calls
|
||||
mock_tool_call = Mock()
|
||||
mock_tool_call.function = Mock()
|
||||
mock_tool_call.function.name = 'test_function'
|
||||
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Processing..."
|
||||
assistant_resp.tool_calls = [mock_tool_call]
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Processing...")])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 2
|
||||
for result in results:
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert app.plugin_connector.emit_event.await_count == 2
|
||||
|
||||
|
||||
class TestResponseWrapperInterrupt:
|
||||
"""Tests for INTERRUPT behavior when plugin prevents default."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_prevented_interrupts(self):
|
||||
"""Plugin event prevented should return INTERRUPT."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Mock session manager to return a valid Session
|
||||
session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector - event is prevented
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=True)
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response with content
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello!"
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello!")])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.INTERRUPT
|
||||
|
||||
|
||||
class TestResponseWrapperCustomReply:
|
||||
"""Tests for custom reply from plugin event."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reply_chain_used(self):
|
||||
"""Plugin reply_message_chain should replace default."""
|
||||
wrapper = get_wrapper_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Mock session manager to return a valid Session
|
||||
session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector with custom reply
|
||||
custom_chain = platform_message.MessageChain([platform_message.Plain(text="Custom reply")])
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = custom_chain
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Create assistant response
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Default reply"
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Default reply")])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].result_type == entities.ResultType.CONTINUE
|
||||
# Custom chain should be in resp_message_chain
|
||||
assert len(results[0].new_query.resp_message_chain) == 1
|
||||
# Should be the custom chain
|
||||
chain = results[0].new_query.resp_message_chain[0]
|
||||
assert "Custom reply" in str(chain)
|
||||
|
||||
|
||||
class TestResponseWrapperVariables:
|
||||
"""Tests for bound plugins variable."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bound_plugins_passed_to_event(self):
|
||||
"""_pipeline_bound_plugins should be passed to emit_event."""
|
||||
wrapper = get_wrapper_module()
|
||||
get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
|
||||
# Mock session manager to return a valid Session
|
||||
session = make_session()
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
|
||||
# Mock plugin connector
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.is_prevented_default = Mock(return_value=False)
|
||||
mock_event_ctx.event = Mock()
|
||||
mock_event_ctx.event.reply_message_chain = None
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
query.variables['_pipeline_bound_plugins'] = ['plugin1', 'plugin2']
|
||||
|
||||
# Create assistant response
|
||||
assistant_resp = Mock()
|
||||
assistant_resp.role = 'assistant'
|
||||
assistant_resp.content = "Hello"
|
||||
assistant_resp.tool_calls = None
|
||||
assistant_resp.get_content_platform_message_chain = Mock(
|
||||
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello")])
|
||||
)
|
||||
query.resp_messages = [assistant_resp]
|
||||
|
||||
results = []
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
# Check that bound_plugins was passed
|
||||
emit_call = app.plugin_connector.emit_event.call_args
|
||||
assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins
|
||||
Reference in New Issue
Block a user