mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 16:26:02 +00:00
perf(agent-runner): improve session registry and orchestrator efficiency
- Add pre-computed _authorized_ids (frozenset) at session registration for O(1) lookup - Refactor is_resource_allowed() from linear search to set membership check - Add thread-safe locking to get_session_registry() singleton - Cache _session_registry and _state_store references in orchestrator __init__ - Add asyncio.gather() for parallel resource building in AgentResourceBuilder - Create shared test fixtures in tests/unit_tests/agent/conftest.py - Update test files to import from shared conftest.py Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
75
tests/unit_tests/agent/conftest.py
Normal file
75
tests/unit_tests/agent/conftest.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Shared test fixtures for agent runner tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
def make_resources(
|
||||
models: list[dict] | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
knowledge_bases: list[dict] | None = None,
|
||||
storage: dict | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create a minimal AgentResources dict for testing.
|
||||
|
||||
Args:
|
||||
models: List of model dicts with 'model_id' key
|
||||
tools: List of tool dicts with 'tool_name' key
|
||||
knowledge_bases: List of KB dicts with 'kb_id' key
|
||||
storage: Storage permissions dict
|
||||
|
||||
Returns:
|
||||
AgentResources dict with all required fields
|
||||
"""
|
||||
return {
|
||||
'models': models or [],
|
||||
'tools': tools or [],
|
||||
'knowledge_bases': knowledge_bases or [],
|
||||
'files': [],
|
||||
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
|
||||
'platform_capabilities': {},
|
||||
}
|
||||
|
||||
|
||||
def make_session(
|
||||
run_id: str = 'test-run-id',
|
||||
runner_id: str = 'plugin:test/test-runner/default',
|
||||
query_id: int | None = 1,
|
||||
plugin_identity: str = 'test/test-runner',
|
||||
resources: dict | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create a minimal AgentRunSession dict for testing.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
runner_id: Runner descriptor ID
|
||||
query_id: Pipeline query ID
|
||||
plugin_identity: Plugin identifier (author/name)
|
||||
resources: AgentResources dict (uses make_resources() default if None)
|
||||
|
||||
Returns:
|
||||
AgentRunSession dict with all required fields including pre-computed _authorized_ids
|
||||
"""
|
||||
import time
|
||||
now = int(time.time())
|
||||
res = resources or make_resources()
|
||||
|
||||
# Pre-compute authorized IDs for O(1) lookup (matching production behavior)
|
||||
authorized_ids: dict[str, set[str]] = {
|
||||
'model': {m.get('model_id') for m in res.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
|
||||
}
|
||||
|
||||
return {
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'plugin_identity': plugin_identity,
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'_authorized_ids': authorized_ids,
|
||||
}
|
||||
275
tests/unit_tests/agent/test_config_migration_full.py
Normal file
275
tests/unit_tests/agent/test_config_migration_full.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for pipeline config migration to new runner format."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
|
||||
class TestMigratePipelineConfig:
|
||||
"""Tests for ConfigMigration.migrate_pipeline_config."""
|
||||
|
||||
def test_migrate_old_local_agent_config(self):
|
||||
"""Old local-agent config should migrate to plugin format."""
|
||||
old_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'runner': 'local-agent',
|
||||
'expire-time': 0,
|
||||
},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'model-uuid', 'fallbacks': []},
|
||||
'max-round': 10,
|
||||
'prompt': [{'role': 'system', 'content': 'Hello'}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
migrated = ConfigMigration.migrate_pipeline_config(old_config)
|
||||
|
||||
# Should have new format
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
|
||||
assert 'runner' not in migrated['ai']['runner'] or migrated['ai']['runner'].get('runner') != 'local-agent'
|
||||
|
||||
# Config should be in runner_config
|
||||
assert 'plugin:langbot/local-agent/default' in migrated['ai']['runner_config']
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
|
||||
|
||||
# Expire-time preserved
|
||||
assert migrated['ai']['runner']['expire-time'] == 0
|
||||
|
||||
def test_migrate_old_dify_service_api_config(self):
|
||||
"""Old dify-service-api config should migrate to dify-agent plugin."""
|
||||
old_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'runner': 'dify-service-api',
|
||||
'expire-time': 300,
|
||||
},
|
||||
'dify-service-api': {
|
||||
'base-url': 'https://api.dify.ai/v1',
|
||||
'api-key': 'test-key',
|
||||
'app-type': 'chat',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
migrated = ConfigMigration.migrate_pipeline_config(old_config)
|
||||
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:langbot/dify-agent/default'
|
||||
assert 'plugin:langbot/dify-agent/default' in migrated['ai']['runner_config']
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/dify-agent/default']['api-key'] == 'test-key'
|
||||
assert migrated['ai']['runner']['expire-time'] == 300
|
||||
|
||||
def test_new_format_config_stays_unchanged(self):
|
||||
"""New format config should not change."""
|
||||
new_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'id': 'plugin:langbot/local-agent/default',
|
||||
'expire-time': 0,
|
||||
},
|
||||
'runner_config': {
|
||||
'plugin:langbot/local-agent/default': {
|
||||
'model': {'primary': '', 'fallbacks': []},
|
||||
'max-round': 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
migrated = ConfigMigration.migrate_pipeline_config(new_config)
|
||||
|
||||
# Should remain unchanged
|
||||
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
|
||||
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
|
||||
|
||||
def test_migrate_all_old_runners(self):
|
||||
"""All old runner names should be migrated."""
|
||||
old_runners = [
|
||||
'local-agent',
|
||||
'dify-service-api',
|
||||
'n8n-service-api',
|
||||
'coze-api',
|
||||
'dashscope-app-api',
|
||||
'langflow-api',
|
||||
'tbox-app-api',
|
||||
]
|
||||
|
||||
expected_ids = [
|
||||
'plugin:langbot/local-agent/default',
|
||||
'plugin:langbot/dify-agent/default',
|
||||
'plugin:langbot/n8n-agent/default',
|
||||
'plugin:langbot/coze-agent/default',
|
||||
'plugin:langbot/dashscope-agent/default',
|
||||
'plugin:langbot/langflow-agent/default',
|
||||
'plugin:langbot/tbox-agent/default',
|
||||
]
|
||||
|
||||
for old_runner, expected_id in zip(old_runners, expected_ids):
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'runner': old_runner, 'expire-time': 0},
|
||||
old_runner: {'test-key': 'test-value'},
|
||||
},
|
||||
}
|
||||
migrated = ConfigMigration.migrate_pipeline_config(config)
|
||||
assert migrated['ai']['runner']['id'] == expected_id
|
||||
assert expected_id in migrated['ai']['runner_config']
|
||||
|
||||
def test_migrate_empty_config(self):
|
||||
"""Empty config should not break."""
|
||||
config = {}
|
||||
migrated = ConfigMigration.migrate_pipeline_config(config)
|
||||
assert migrated == {}
|
||||
|
||||
def test_migrate_config_without_ai_section(self):
|
||||
"""Config without ai section should not break."""
|
||||
config = {'trigger': {}}
|
||||
migrated = ConfigMigration.migrate_pipeline_config(config)
|
||||
assert 'trigger' in migrated
|
||||
|
||||
def test_expire_time_preserved(self):
|
||||
"""expire-time should be preserved during migration."""
|
||||
old_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'runner': 'local-agent',
|
||||
'expire-time': 3600,
|
||||
},
|
||||
'local-agent': {},
|
||||
},
|
||||
}
|
||||
|
||||
migrated = ConfigMigration.migrate_pipeline_config(old_config)
|
||||
assert migrated['ai']['runner']['expire-time'] == 3600
|
||||
|
||||
|
||||
class TestDefaultPipelineConfig:
|
||||
"""Tests for default-pipeline-config.json format."""
|
||||
|
||||
def test_default_config_is_new_format(self):
|
||||
"""Default pipeline config should use new format."""
|
||||
from langbot.pkg.utils import paths as path_utils
|
||||
|
||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Should have new format
|
||||
assert 'ai' in config
|
||||
assert 'runner' in config['ai']
|
||||
assert 'id' in config['ai']['runner']
|
||||
assert config['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
|
||||
|
||||
# Should have runner_config with local-agent default
|
||||
assert 'runner_config' in config['ai']
|
||||
assert 'plugin:langbot/local-agent/default' in config['ai']['runner_config']
|
||||
|
||||
# Should NOT have old local-agent key
|
||||
assert 'local-agent' not in config['ai']
|
||||
|
||||
def test_default_config_has_model_config(self):
|
||||
"""Default config should have model config in runner_config."""
|
||||
from langbot.pkg.utils import paths as path_utils
|
||||
|
||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
runner_config = config['ai']['runner_config']['plugin:langbot/local-agent/default']
|
||||
assert 'model' in runner_config
|
||||
assert 'max-round' in runner_config
|
||||
assert 'prompt' in runner_config
|
||||
|
||||
|
||||
class TestResolveRunnerIdBackwardCompat:
|
||||
"""Tests for backward compatibility in resolve_runner_id."""
|
||||
|
||||
def test_resolve_new_format_id(self):
|
||||
"""resolve_runner_id should work with new format."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:test/my-runner/default'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:test/my-runner/default'
|
||||
|
||||
def test_resolve_old_format_runner(self):
|
||||
"""resolve_runner_id should map old format to plugin ID."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
def test_resolve_plugin_format_in_runner_field(self):
|
||||
"""resolve_runner_id should handle plugin:* in runner field."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'plugin:langbot/local-agent/default'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:langbot/local-agent/default'
|
||||
|
||||
def test_resolve_new_format_priority(self):
|
||||
"""New format id should take priority over old runner field."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'id': 'plugin:new-runner/default',
|
||||
'runner': 'local-agent', # Old field, should be ignored
|
||||
},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:new-runner/default'
|
||||
|
||||
|
||||
class TestResolveRunnerConfigBackwardCompat:
|
||||
"""Tests for backward compatibility in resolve_runner_config."""
|
||||
|
||||
def test_resolve_new_format_config(self):
|
||||
"""resolve_runner_config should read from runner_config."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner_config': {
|
||||
'plugin:langbot/local-agent/default': {'max-round': 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(
|
||||
config, 'plugin:langbot/local-agent/default'
|
||||
)
|
||||
assert runner_config['max-round'] == 20
|
||||
|
||||
def test_resolve_old_format_config(self):
|
||||
"""resolve_runner_config should read from old ai.local-agent."""
|
||||
config = {
|
||||
'ai': {
|
||||
'local-agent': {'max-round': 15},
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(
|
||||
config, 'plugin:langbot/local-agent/default'
|
||||
)
|
||||
assert runner_config['max-round'] == 15
|
||||
|
||||
def test_resolve_new_format_priority(self):
|
||||
"""New format runner_config should take priority."""
|
||||
config = {
|
||||
'ai': {
|
||||
'runner_config': {
|
||||
'plugin:langbot/local-agent/default': {'max-round': 25},
|
||||
},
|
||||
'local-agent': {'max-round': 10}, # Old, should be ignored
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(
|
||||
config, 'plugin:langbot/local-agent/default'
|
||||
)
|
||||
assert runner_config['max-round'] == 25
|
||||
449
tests/unit_tests/agent/test_context_builder_params_state.py
Normal file
449
tests/unit_tests/agent/test_context_builder_params_state.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""Tests for agent run context builder params and state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.state_store import reset_state_store
|
||||
|
||||
# Import shared test fixtures from conftest.py
|
||||
from .conftest import make_resources
|
||||
|
||||
|
||||
class FakeApplication:
|
||||
"""Fake Application for testing."""
|
||||
def __init__(self):
|
||||
class FakeLogger:
|
||||
def info(self, msg):
|
||||
pass
|
||||
def debug(self, msg):
|
||||
pass
|
||||
def warning(self, msg):
|
||||
pass
|
||||
def error(self, msg):
|
||||
pass
|
||||
|
||||
class FakeVersionManager:
|
||||
def get_current_version(self):
|
||||
return '1.0.0'
|
||||
|
||||
self.logger = FakeLogger()
|
||||
self.ver_mgr = FakeVersionManager()
|
||||
|
||||
|
||||
def make_descriptor() -> AgentRunnerDescriptor:
|
||||
"""Create a test descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
id='plugin:langbot/local-agent/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
plugin_author='langbot',
|
||||
plugin_name='local-agent',
|
||||
runner_name='default',
|
||||
protocol_version='1',
|
||||
capabilities={'streaming': True},
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Fake session for testing."""
|
||||
def __init__(self):
|
||||
self.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
|
||||
self.launcher_id = 'group_123'
|
||||
self.using_conversation = None
|
||||
|
||||
|
||||
class FakeConversation:
|
||||
"""Fake conversation for testing."""
|
||||
def __init__(self, uuid: str = 'conv_abc'):
|
||||
self.uuid = uuid
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
"""Fake message for testing."""
|
||||
def __init__(self, content='Hello'):
|
||||
self.content = content
|
||||
self.role = 'user'
|
||||
|
||||
|
||||
class TestBuildParams:
|
||||
"""Tests for _build_params filtering."""
|
||||
|
||||
def test_params_empty_when_no_variables(self):
|
||||
"""Empty variables should produce empty params."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': None,
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
assert params == {}
|
||||
|
||||
def test_params_filters_underscore_prefix(self):
|
||||
"""Params should exclude variables starting with underscore."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'_internal_var': 'should_be_excluded',
|
||||
'_pipeline_bound_plugins': ['a/b'],
|
||||
'_monitoring_bot_name': 'Bot',
|
||||
'public_var': 'should_be_included',
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
assert '_internal_var' not in params
|
||||
assert '_pipeline_bound_plugins' not in params
|
||||
assert '_monitoring_bot_name' not in params
|
||||
assert 'public_var' in params
|
||||
assert params['public_var'] == 'should_be_included'
|
||||
|
||||
def test_params_filters_sensitive_naming(self):
|
||||
"""Params should exclude variables with sensitive naming patterns."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'api_key': 'secret123',
|
||||
'API_KEY': 'secret456',
|
||||
'token': 'tok123',
|
||||
'secret': 'sec123',
|
||||
'password': 'pass123',
|
||||
'credential': 'cred123',
|
||||
'user_api_key': 'should_be_excluded',
|
||||
'user_secret_key': 'should_be_excluded',
|
||||
'my_token_value': 'should_be_excluded',
|
||||
'user_password_hash': 'should_be_excluded',
|
||||
'public_name': 'should_be_included',
|
||||
'safe_value': 'should_be_included',
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# All sensitive patterns should be excluded
|
||||
assert 'api_key' not in params
|
||||
assert 'API_KEY' not in params
|
||||
assert 'token' not in params
|
||||
assert 'secret' not in params
|
||||
assert 'password' not in params
|
||||
assert 'credential' not in params
|
||||
assert 'user_api_key' not in params
|
||||
assert 'user_secret_key' not in params
|
||||
assert 'my_token_value' not in params
|
||||
assert 'user_password_hash' not in params
|
||||
# Public vars should be included
|
||||
assert 'public_name' in params
|
||||
assert 'safe_value' in params
|
||||
|
||||
def test_params_keeps_common_public_vars(self):
|
||||
"""Params should keep common public business vars."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'launcher_type': 'telegram',
|
||||
'launcher_id': 'group_123',
|
||||
'sender_id': 'user_001',
|
||||
'session_id': 'sess_abc',
|
||||
'msg_create_time': 1234567890,
|
||||
'group_name': 'Tech Group',
|
||||
'sender_name': 'John',
|
||||
'user_message_text': 'Hello world',
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# All these should be included
|
||||
assert params['launcher_type'] == 'telegram'
|
||||
assert params['launcher_id'] == 'group_123'
|
||||
assert params['sender_id'] == 'user_001'
|
||||
assert params['session_id'] == 'sess_abc'
|
||||
assert params['msg_create_time'] == 1234567890
|
||||
assert params['group_name'] == 'Tech Group'
|
||||
assert params['sender_name'] == 'John'
|
||||
assert params['user_message_text'] == 'Hello world'
|
||||
|
||||
def test_params_filters_non_json_serializable(self):
|
||||
"""Params should keep only JSON-serializable values."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'string_value': 'hello',
|
||||
'int_value': 42,
|
||||
'float_value': 3.14,
|
||||
'bool_value': True,
|
||||
'null_value': None,
|
||||
'list_value': ['a', 'b', 'c'],
|
||||
'dict_value': {'nested': 'value'},
|
||||
'custom_object': CustomObject(), # Not serializable
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
assert 'string_value' in params
|
||||
assert 'int_value' in params
|
||||
assert 'float_value' in params
|
||||
assert 'bool_value' in params
|
||||
assert 'null_value' in params
|
||||
assert 'list_value' in params
|
||||
assert 'dict_value' in params
|
||||
assert 'custom_object' not in params
|
||||
|
||||
def test_params_filters_nested_non_serializable(self):
|
||||
"""Params should filter nested non-serializable values."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'nested_list_with_bad': ['a', CustomObject(), 'c'], # List with non-serializable
|
||||
'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()}, # Dict with non-serializable
|
||||
'good_nested_list': ['a', ['b', 'c']],
|
||||
'good_nested_dict': {'outer': {'inner': 'value'}},
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# Nested with bad should be excluded
|
||||
assert 'nested_list_with_bad' not in params
|
||||
assert 'nested_dict_with_bad' not in params
|
||||
# Good nested should be included
|
||||
assert 'good_nested_list' in params
|
||||
assert 'good_nested_dict' in params
|
||||
|
||||
def test_is_json_serializable_primitives(self):
|
||||
"""_is_json_serializable should return True for primitives."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
assert builder._is_json_serializable(None) is True
|
||||
assert builder._is_json_serializable('string') is True
|
||||
assert builder._is_json_serializable(42) is True
|
||||
assert builder._is_json_serializable(3.14) is True
|
||||
assert builder._is_json_serializable(True) is True
|
||||
assert builder._is_json_serializable(False) is True
|
||||
|
||||
def test_is_json_serializable_collections(self):
|
||||
"""_is_json_serializable should check nested collections."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
assert builder._is_json_serializable([]) is True
|
||||
assert builder._is_json_serializable(['a', 'b']) is True
|
||||
assert builder._is_json_serializable({}) is True
|
||||
assert builder._is_json_serializable({'key': 'value'}) is True
|
||||
assert builder._is_json_serializable([1, 2, [3, 4]]) is True
|
||||
assert builder._is_json_serializable({'a': {'b': 'c'}}) is True
|
||||
|
||||
def test_is_json_serializable_custom_objects(self):
|
||||
"""_is_json_serializable should return False for custom objects."""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
assert builder._is_json_serializable(CustomObject()) is False
|
||||
assert builder._is_json_serializable([CustomObject()]) is False
|
||||
assert builder._is_json_serializable({'key': CustomObject()}) is False
|
||||
|
||||
def test_is_json_serializable_set_not_allowed(self):
|
||||
"""_is_json_serializable should return False for set (not JSON-serializable).
|
||||
|
||||
json.dumps({"x": {1}}) fails because set is not JSON-serializable.
|
||||
Only list and tuple are allowed.
|
||||
"""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
# set is NOT JSON-serializable
|
||||
assert builder._is_json_serializable({1, 2, 3}) is False
|
||||
assert builder._is_json_serializable({'a', 'b'}) is False
|
||||
# list and tuple ARE allowed
|
||||
assert builder._is_json_serializable([1, 2, 3]) is True
|
||||
assert builder._is_json_serializable((1, 2, 3)) is True
|
||||
# Nested set should also be rejected
|
||||
assert builder._is_json_serializable([1, {2, 3}]) is False
|
||||
assert builder._is_json_serializable({'key': {1, 2}}) is False
|
||||
|
||||
def test_params_filters_set_values(self):
|
||||
"""Params should filter out variables with set values.
|
||||
|
||||
set is not JSON-serializable and would cause json.dumps to fail.
|
||||
"""
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
|
||||
query = type('Query', (), {
|
||||
'variables': {
|
||||
'list_value': ['a', 'b', 'c'],
|
||||
'tuple_value': ('a', 'b', 'c'),
|
||||
'set_value': {'a', 'b', 'c'}, # Should be filtered
|
||||
'nested_with_set': ['a', {'b', 'c'}], # Should be filtered
|
||||
'dict_with_set': {'items': {1, 2}}, # Should be filtered
|
||||
},
|
||||
})()
|
||||
|
||||
params = builder._build_params(query)
|
||||
# list and tuple should be included
|
||||
assert 'list_value' in params
|
||||
assert params['list_value'] == ['a', 'b', 'c']
|
||||
assert 'tuple_value' in params
|
||||
# set should be filtered
|
||||
assert 'set_value' not in params
|
||||
assert 'nested_with_set' not in params
|
||||
assert 'dict_with_set' not in params
|
||||
|
||||
|
||||
class TestBuildState:
|
||||
"""Tests for state snapshot building."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_has_state_field(self):
|
||||
"""AgentRunContextV1 should have state field."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
session = FakeSession()
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
assert 'state' in context
|
||||
assert 'conversation' in context['state']
|
||||
assert 'actor' in context['state']
|
||||
assert 'subject' in context['state']
|
||||
assert 'runner' in context['state']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_seeds_conversation_id_from_existing(self):
|
||||
"""State should seed external.conversation_id from existing conversation uuid."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
conversation = FakeConversation(uuid='conv_existing')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
assert context['state']['conversation']['external.conversation_id'] == 'conv_existing'
|
||||
|
||||
|
||||
class TestBuildParamsInContext:
|
||||
"""Tests for params in full context."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_has_params_field(self):
|
||||
"""AgentRunContextV1 should have params field."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
session = FakeSession()
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {
|
||||
'public_param': 'value',
|
||||
'_private': 'excluded',
|
||||
},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
assert 'params' in context
|
||||
assert context['params']['public_param'] == 'value'
|
||||
assert '_private' not in context['params']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_params_and_state_both_present(self):
|
||||
"""Context should have both params and state."""
|
||||
reset_state_store()
|
||||
ap = FakeApplication()
|
||||
builder = AgentRunContextBuilder(ap)
|
||||
descriptor = make_descriptor()
|
||||
resources = make_resources()
|
||||
|
||||
conversation = FakeConversation(uuid='conv_abc')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = type('Query', (), {
|
||||
'query_id': 1,
|
||||
'bot_uuid': 'bot_001',
|
||||
'pipeline_uuid': 'pipeline_001',
|
||||
'sender_id': 'user_001',
|
||||
'session': session,
|
||||
'user_message': None,
|
||||
'message_chain': None,
|
||||
'messages': [],
|
||||
'pipeline_config': {},
|
||||
'variables': {
|
||||
'workflow_input': 'user_question',
|
||||
'sender_name': 'John',
|
||||
},
|
||||
})()
|
||||
|
||||
context = await builder.build_context(query, descriptor, resources)
|
||||
|
||||
# params should have public vars
|
||||
assert 'params' in context
|
||||
assert context['params']['workflow_input'] == 'user_question'
|
||||
assert context['params']['sender_name'] == 'John'
|
||||
|
||||
# state should have seeded conversation_id
|
||||
assert 'state' in context
|
||||
assert context['state']['conversation']['external.conversation_id'] == 'conv_abc'
|
||||
1617
tests/unit_tests/agent/test_handler_auth.py
Normal file
1617
tests/unit_tests/agent/test_handler_auth.py
Normal file
File diff suppressed because it is too large
Load Diff
427
tests/unit_tests/agent/test_session_registry.py
Normal file
427
tests/unit_tests/agent/test_session_registry.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""Tests for AgentRunSessionRegistry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from langbot.pkg.agent.runner.session_registry import (
|
||||
AgentRunSessionRegistry,
|
||||
AgentRunSession,
|
||||
get_session_registry,
|
||||
)
|
||||
|
||||
# Import shared test fixtures from conftest.py
|
||||
from .conftest import make_resources, make_session
|
||||
|
||||
|
||||
class TestSessionRegistryBasic:
|
||||
"""Tests for basic registry operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_and_get(self):
|
||||
"""Register and retrieve a session."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
run_id = 'run_abc'
|
||||
resources = make_resources(
|
||||
models=[{'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}],
|
||||
tools=[{'tool_name': 'web_search', 'tool_type': 'builtin'}],
|
||||
)
|
||||
session = make_session(run_id=run_id, resources=resources)
|
||||
|
||||
await registry.register(
|
||||
run_id=run_id,
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
result = await registry.get(run_id)
|
||||
assert result is not None
|
||||
assert result['run_id'] == run_id
|
||||
assert result['runner_id'] == 'plugin:test/my-runner/default'
|
||||
assert result['query_id'] == 1
|
||||
assert result['plugin_identity'] == 'test/my-runner'
|
||||
assert len(result['resources']['models']) == 1
|
||||
assert result['resources']['models'][0]['model_id'] == 'model_001'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_session(self):
|
||||
"""Get should return None for nonexistent run_id."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
result = await registry.get('nonexistent_run')
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister(self):
|
||||
"""Unregister should remove session."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
run_id = 'run_xyz'
|
||||
|
||||
await registry.register(
|
||||
run_id=run_id,
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=make_resources(),
|
||||
)
|
||||
|
||||
# Verify registered
|
||||
result = await registry.get(run_id)
|
||||
assert result is not None
|
||||
|
||||
# Unregister
|
||||
await registry.unregister(run_id)
|
||||
|
||||
# Verify unregistered
|
||||
result = await registry.get(run_id)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_nonexistent(self):
|
||||
"""Unregister nonexistent session should not raise error."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
# Should not raise
|
||||
await registry.unregister('nonexistent_run')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_activity(self):
|
||||
"""Update activity should update last_activity_at."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
run_id = 'run_activity'
|
||||
|
||||
# Create session with manually set old timestamp
|
||||
now = int(time.time())
|
||||
res = make_resources()
|
||||
old_session: AgentRunSession = {
|
||||
'run_id': run_id,
|
||||
'runner_id': 'plugin:test/my-runner/default',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test/my-runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now - 100, # 100 seconds ago
|
||||
'last_activity_at': now - 100, # 100 seconds ago
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
},
|
||||
}
|
||||
|
||||
async with registry._lock:
|
||||
registry._sessions[run_id] = old_session
|
||||
|
||||
# Get initial session
|
||||
session1 = await registry.get(run_id)
|
||||
initial_time = session1['status']['last_activity_at']
|
||||
|
||||
# Update activity
|
||||
await registry.update_activity(run_id)
|
||||
|
||||
# Verify updated - should be significantly different (100 seconds)
|
||||
session2 = await registry.get(run_id)
|
||||
assert session2['status']['last_activity_at'] > initial_time
|
||||
assert session2['status']['last_activity_at'] - initial_time >= 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_activity_nonexistent(self):
|
||||
"""Update activity on nonexistent session should not raise."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
# Should not raise
|
||||
await registry.update_activity('nonexistent_run')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_active_runs(self):
|
||||
"""List active runs should return all sessions."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
await registry.register('run_1', 'plugin:a/b/default', 1, 'a/b', make_resources())
|
||||
await registry.register('run_2', 'plugin:c/d/default', 2, 'c/d', make_resources())
|
||||
|
||||
active_runs = await registry.list_active_runs()
|
||||
assert len(active_runs) == 2
|
||||
run_ids = [r['run_id'] for r in active_runs]
|
||||
assert 'run_1' in run_ids
|
||||
assert 'run_2' in run_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_sessions(self):
|
||||
"""Cleanup should remove old sessions."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
# Create sessions with manually set old timestamp
|
||||
now = int(time.time())
|
||||
res = make_resources()
|
||||
old_session: AgentRunSession = {
|
||||
'run_id': 'old_run',
|
||||
'runner_id': 'plugin:test/runner/default',
|
||||
'query_id': 1,
|
||||
'plugin_identity': 'test/runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now - 7200, # 2 hours ago
|
||||
'last_activity_at': now - 7200, # 2 hours ago
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
},
|
||||
}
|
||||
new_session: AgentRunSession = {
|
||||
'run_id': 'new_run',
|
||||
'runner_id': 'plugin:test/runner/default',
|
||||
'query_id': 2,
|
||||
'plugin_identity': 'test/runner',
|
||||
'resources': res,
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'_authorized_ids': {
|
||||
'model': set(),
|
||||
'tool': set(),
|
||||
'knowledge_base': set(),
|
||||
},
|
||||
}
|
||||
|
||||
async with registry._lock:
|
||||
registry._sessions['old_run'] = old_session
|
||||
registry._sessions['new_run'] = new_session
|
||||
|
||||
# Cleanup sessions older than 1 hour
|
||||
cleaned = await registry.cleanup_stale_sessions(max_age_seconds=3600)
|
||||
assert cleaned == 1
|
||||
|
||||
# Verify old session removed, new remains
|
||||
assert await registry.get('old_run') is None
|
||||
assert await registry.get('new_run') is not None
|
||||
|
||||
|
||||
class TestIsResourceAllowed:
|
||||
"""Tests for is_resource_allowed validation."""
|
||||
|
||||
def test_model_allowed(self):
|
||||
"""Model in resources should be allowed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
models=[
|
||||
{'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'},
|
||||
{'model_id': 'model_002', 'model_type': 'embedding', 'provider': 'anthropic'},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001') is True
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_002') is True
|
||||
|
||||
def test_model_not_allowed(self):
|
||||
"""Model not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_999') is False
|
||||
|
||||
def test_model_empty_resources(self):
|
||||
"""Empty models list should deny all."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(models=[])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'model', 'model_001') is False
|
||||
|
||||
def test_tool_allowed(self):
|
||||
"""Tool in resources should be allowed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
tools=[
|
||||
{'tool_name': 'web_search', 'tool_type': 'builtin'},
|
||||
{'tool_name': 'code_exec', 'tool_type': 'plugin'},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search') is True
|
||||
assert registry.is_resource_allowed(session, 'tool', 'code_exec') is True
|
||||
|
||||
def test_tool_not_allowed(self):
|
||||
"""Tool not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(tools=[{'tool_name': 'web_search'}])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False
|
||||
|
||||
def test_tool_empty_resources(self):
|
||||
"""Empty tools list should deny all."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(tools=[])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search') is False
|
||||
|
||||
def test_knowledge_base_allowed(self):
|
||||
"""Knowledge base in resources should be allowed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
knowledge_bases=[
|
||||
{'kb_id': 'kb_001', 'kb_name': 'docs', 'kb_type': 'vector'},
|
||||
{'kb_id': 'kb_002', 'kb_name': 'faq', 'kb_type': 'keyword'},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True
|
||||
|
||||
def test_knowledge_base_not_allowed(self):
|
||||
"""Knowledge base not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False
|
||||
|
||||
def test_knowledge_base_empty_resources(self):
|
||||
"""Empty knowledge bases list should deny all."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(knowledge_bases=[])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
|
||||
|
||||
def test_storage_plugin_allowed(self):
|
||||
"""Plugin storage permission should be checked."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False})
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'storage', 'plugin') is True
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
|
||||
|
||||
def test_storage_workspace_allowed(self):
|
||||
"""Workspace storage permission should be checked."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True})
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is True
|
||||
|
||||
def test_storage_both_denied(self):
|
||||
"""Both storage permissions denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
|
||||
|
||||
def test_unknown_resource_type(self):
|
||||
"""Unknown resource type should return False."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
session = make_session(resources=make_resources())
|
||||
|
||||
assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False
|
||||
|
||||
def test_missing_resources_field(self):
|
||||
"""Missing resources field should not raise."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
session = make_session(resources={'models': []}) # Missing other fields
|
||||
|
||||
# Should not raise, should return False
|
||||
assert registry.is_resource_allowed(session, 'tool', 'web_search') is False
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
|
||||
|
||||
|
||||
class TestGlobalRegistry:
|
||||
"""Tests for global registry singleton."""
|
||||
|
||||
def test_get_session_registry_returns_instance(self):
|
||||
"""get_session_registry should return AgentRunSessionRegistry."""
|
||||
# Use a separate test that doesn't modify global state
|
||||
# The singleton pattern works in production, but modifying globals
|
||||
# in tests can cause UnboundLocalError due to Python scoping
|
||||
# Instead, just verify the function signature
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
assert callable(get_session_registry)
|
||||
|
||||
# Create a fresh instance directly to verify the class works
|
||||
fresh_registry = AgentRunSessionRegistry()
|
||||
assert isinstance(fresh_registry, AgentRunSessionRegistry)
|
||||
|
||||
def test_global_registry_singleton_behavior(self):
|
||||
"""The global registry should maintain singleton behavior."""
|
||||
# Test singleton behavior without modifying global state
|
||||
# In production, calling get_session_registry() multiple times
|
||||
# returns the same instance. We verify this by checking the
|
||||
# module-level variable directly.
|
||||
import langbot.pkg.agent.runner.session_registry as registry_module
|
||||
|
||||
# Check that the global variable exists and is either None or an instance
|
||||
global_reg = registry_module._global_registry
|
||||
if global_reg is None:
|
||||
# First call creates the instance
|
||||
registry1 = get_session_registry()
|
||||
assert isinstance(registry1, AgentRunSessionRegistry)
|
||||
# Subsequent calls return the same instance
|
||||
registry2 = get_session_registry()
|
||||
assert registry1 is registry2
|
||||
else:
|
||||
# Instance already exists, verify singleton
|
||||
registry1 = get_session_registry()
|
||||
registry2 = get_session_registry()
|
||||
assert registry1 is registry2
|
||||
assert registry1 is global_reg
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Tests for asyncio.Lock thread safety."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_register(self):
|
||||
"""Concurrent register should be safe."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
# Register multiple sessions concurrently
|
||||
tasks = []
|
||||
for i in range(10):
|
||||
tasks.append(
|
||||
registry.register(
|
||||
f'run_{i}',
|
||||
'plugin:test/runner/default',
|
||||
i,
|
||||
'test/runner',
|
||||
make_resources(),
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# All sessions should be registered
|
||||
active_runs = await registry.list_active_runs()
|
||||
assert len(active_runs) == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_register_and_unregister(self):
|
||||
"""Concurrent register and unregister should be safe."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
# Register
|
||||
await registry.register('run_1', 'plugin:test/runner/default', 1, 'test/runner', make_resources())
|
||||
|
||||
# Concurrent unregister and get
|
||||
tasks = [
|
||||
registry.unregister('run_1'),
|
||||
registry.get('run_1'),
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# After both complete, session should be unregistered
|
||||
result = await registry.get('run_1')
|
||||
assert result is None
|
||||
473
tests/unit_tests/agent/test_state_store.py
Normal file
473
tests/unit_tests/agent/test_state_store.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Tests for runner scoped state store."""
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.agent.runner.state_store import (
|
||||
RunnerScopedStateStore,
|
||||
get_state_store,
|
||||
reset_state_store,
|
||||
VALID_STATE_SCOPES,
|
||||
LEGACY_KEY_MAPPING,
|
||||
)
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
|
||||
|
||||
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor:
|
||||
"""Create a test descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
plugin_author='test',
|
||||
plugin_name='my-runner',
|
||||
runner_name='default',
|
||||
protocol_version='1',
|
||||
capabilities={'streaming': True},
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Fake session for testing."""
|
||||
def __init__(self):
|
||||
self.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
|
||||
self.launcher_id = 'group_123'
|
||||
self.using_conversation = None
|
||||
|
||||
|
||||
class FakeConversation:
|
||||
"""Fake conversation for testing."""
|
||||
def __init__(self, uuid: str = 'conv_abc', create_time: int | None = None):
|
||||
self.uuid = uuid
|
||||
self.create_time = create_time
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
"""Fake query for testing."""
|
||||
def __init__(
|
||||
self,
|
||||
bot_uuid: str = 'bot_001',
|
||||
pipeline_uuid: str = 'pipeline_002',
|
||||
sender_id: str = 'user_123',
|
||||
session: FakeSession | None = None,
|
||||
):
|
||||
self.bot_uuid = bot_uuid
|
||||
self.pipeline_uuid = pipeline_uuid
|
||||
self.sender_id = sender_id
|
||||
self.session = session or FakeSession()
|
||||
|
||||
|
||||
class FakeLogger:
|
||||
"""Fake logger for testing."""
|
||||
def __init__(self):
|
||||
self.debugs = []
|
||||
self.warnings = []
|
||||
|
||||
def debug(self, msg):
|
||||
self.debugs.append(msg)
|
||||
|
||||
def warning(self, msg):
|
||||
self.warnings.append(msg)
|
||||
|
||||
|
||||
class TestStateStoreBuildSnapshot:
|
||||
"""Tests for build_snapshot."""
|
||||
|
||||
def test_build_snapshot_returns_four_scopes(self):
|
||||
"""Snapshot should have all four scope keys."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
|
||||
assert 'conversation' in snapshot
|
||||
assert 'actor' in snapshot
|
||||
assert 'subject' in snapshot
|
||||
assert 'runner' in snapshot
|
||||
assert snapshot['conversation'] == {}
|
||||
assert snapshot['actor'] == {}
|
||||
assert snapshot['subject'] == {}
|
||||
assert snapshot['runner'] == {}
|
||||
|
||||
def test_build_snapshot_seeds_conversation_id(self):
|
||||
"""Snapshot should seed external.conversation_id from existing conversation."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
conversation = FakeConversation(uuid='conv_existing')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = FakeQuery(session=session)
|
||||
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
|
||||
assert snapshot['conversation']['external.conversation_id'] == 'conv_existing'
|
||||
|
||||
def test_build_snapshot_returns_stored_values(self):
|
||||
"""Snapshot should return previously stored values."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store some values
|
||||
store.apply_update(query, descriptor, 'conversation', 'external.conversation_id', 'conv_001', logger)
|
||||
store.apply_update(query, descriptor, 'actor', 'preferred_language', 'zh', logger)
|
||||
store.apply_update(query, descriptor, 'subject', 'group_topic', 'tech', logger)
|
||||
store.apply_update(query, descriptor, 'runner', 'cache_version', 'v1', logger)
|
||||
|
||||
# Build snapshot
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
|
||||
assert snapshot['conversation']['external.conversation_id'] == 'conv_001'
|
||||
assert snapshot['actor']['preferred_language'] == 'zh'
|
||||
assert snapshot['subject']['group_topic'] == 'tech'
|
||||
assert snapshot['runner']['cache_version'] == 'v1'
|
||||
|
||||
def test_build_snapshot_isolation_by_runner_id(self):
|
||||
"""Different runner IDs should have isolated state."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor1 = make_descriptor('plugin:test/runner-a/default')
|
||||
descriptor2 = make_descriptor('plugin:test/runner-b/default')
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store for runner-a
|
||||
store.apply_update(query, descriptor1, 'conversation', 'external.conversation_id', 'conv_a', logger)
|
||||
|
||||
# Build snapshot for runner-b
|
||||
snapshot_b = store.build_snapshot(query, descriptor2)
|
||||
|
||||
# runner-b should not see runner-a's state
|
||||
assert snapshot_b['conversation'] == {}
|
||||
|
||||
|
||||
class TestStateStoreApplyUpdate:
|
||||
"""Tests for apply_update."""
|
||||
|
||||
def test_apply_update_conversation_scope(self):
|
||||
"""Apply update to conversation scope."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(
|
||||
query, descriptor, 'conversation', 'external.conversation_id', 'conv_new', logger
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert len(logger.warnings) == 0
|
||||
|
||||
def test_apply_update_actor_scope(self):
|
||||
"""Apply update to actor scope."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(query, descriptor, 'actor', 'preferred_language', 'en', logger)
|
||||
|
||||
assert result is True
|
||||
assert len(logger.warnings) == 0
|
||||
|
||||
def test_apply_update_subject_scope(self):
|
||||
"""Apply update to subject scope."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(query, descriptor, 'subject', 'group_topic', 'general', logger)
|
||||
|
||||
assert result is True
|
||||
assert len(logger.warnings) == 0
|
||||
|
||||
def test_apply_update_runner_scope(self):
|
||||
"""Apply update to runner scope."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(query, descriptor, 'runner', 'cache_version', 'v2', logger)
|
||||
|
||||
assert result is True
|
||||
assert len(logger.warnings) == 0
|
||||
|
||||
def test_apply_update_invalid_scope(self):
|
||||
"""Invalid scope should return False and log warning."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(query, descriptor, 'invalid_scope', 'key', 'value', logger)
|
||||
|
||||
assert result is False
|
||||
assert len(logger.warnings) == 1
|
||||
assert 'invalid scope' in logger.warnings[0]
|
||||
|
||||
def test_apply_update_legacy_key_mapping(self):
|
||||
"""Legacy key conversation_id should be mapped to external.conversation_id."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(query, descriptor, 'conversation', 'conversation_id', 'conv_old', logger)
|
||||
|
||||
assert result is True
|
||||
assert 'mapped to' in logger.debugs[0]
|
||||
|
||||
# Check mapped key is stored
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
assert snapshot['conversation']['external.conversation_id'] == 'conv_old'
|
||||
|
||||
def test_apply_update_syncs_conversation_uuid(self):
|
||||
"""external.conversation_id update should sync to query.session.using_conversation.uuid."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
conversation = FakeConversation(uuid='conv_old')
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = FakeQuery(session=session)
|
||||
logger = FakeLogger()
|
||||
|
||||
result = store.apply_update(
|
||||
query, descriptor, 'conversation', 'external.conversation_id', 'conv_new', logger
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert conversation.uuid == 'conv_new' # Synced
|
||||
assert 'Synced' in logger.debugs[-1]
|
||||
|
||||
|
||||
class TestStateStoreScopeIdentity:
|
||||
"""Tests for scope identity isolation."""
|
||||
|
||||
def test_conversation_scope_includes_runner_id(self):
|
||||
"""Conversation scope key should include runner_id."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor_a = make_descriptor('plugin:test/runner-a/default')
|
||||
descriptor_b = make_descriptor('plugin:test/runner-b/default')
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store for runner-a
|
||||
store.apply_update(query, descriptor_a, 'conversation', 'key', 'value_a', logger)
|
||||
|
||||
# runner-b should not see runner-a's conversation state
|
||||
snapshot_b = store.build_snapshot(query, descriptor_b)
|
||||
assert snapshot_b['conversation'] == {}
|
||||
|
||||
# runner-a should see its own state
|
||||
snapshot_a = store.build_snapshot(query, descriptor_a)
|
||||
assert snapshot_a['conversation']['key'] == 'value_a'
|
||||
|
||||
def test_actor_scope_includes_sender_id(self):
|
||||
"""Actor scope should be isolated per sender_id."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
query_user1 = FakeQuery(sender_id='user_001')
|
||||
query_user2 = FakeQuery(sender_id='user_002')
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store for user_001
|
||||
store.apply_update(query_user1, descriptor, 'actor', 'preferred_language', 'en', logger)
|
||||
|
||||
# user_002 should not see user_001's actor state
|
||||
snapshot_user2 = store.build_snapshot(query_user2, descriptor)
|
||||
assert snapshot_user2['actor'] == {}
|
||||
|
||||
# user_001 should see its own state
|
||||
snapshot_user1 = store.build_snapshot(query_user1, descriptor)
|
||||
assert snapshot_user1['actor']['preferred_language'] == 'en'
|
||||
|
||||
def test_subject_scope_includes_launcher(self):
|
||||
"""Subject scope should be isolated per launcher_type + launcher_id."""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
session1 = FakeSession()
|
||||
session1.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
|
||||
session1.launcher_id = 'group_001'
|
||||
session2 = FakeSession()
|
||||
session2.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
|
||||
session2.launcher_id = 'group_002'
|
||||
query1 = FakeQuery(session=session1)
|
||||
query2 = FakeQuery(session=session2)
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store for group_001
|
||||
store.apply_update(query1, descriptor, 'subject', 'group_topic', 'tech', logger)
|
||||
|
||||
# group_002 should not see group_001's subject state
|
||||
snapshot2 = store.build_snapshot(query2, descriptor)
|
||||
assert snapshot2['subject'] == {}
|
||||
|
||||
# group_001 should see its own state
|
||||
snapshot1 = store.build_snapshot(query1, descriptor)
|
||||
assert snapshot1['subject']['group_topic'] == 'tech'
|
||||
|
||||
def test_conversation_scope_not_dependent_on_external_uuid(self):
|
||||
"""Conversation scope identity should NOT use external conversation uuid.
|
||||
|
||||
Using external uuid as scope key would cause state loss when
|
||||
runner updates external.conversation_id:
|
||||
- First run: state saved under key with old uuid
|
||||
- Runner returns new external.conversation_id, synced to conversation.uuid
|
||||
- Next run: scope key uses new uuid, previous state inaccessible
|
||||
|
||||
This test verifies scope key stability when conversation.uuid changes.
|
||||
"""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
# Use stable create_time as conversation identity
|
||||
conversation = FakeConversation(uuid='conv_initial', create_time=12345)
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = FakeQuery(session=session)
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store some conversation state (e.g., memory.summary, external.thread_id)
|
||||
store.apply_update(
|
||||
query, descriptor, 'conversation', 'memory.summary', 'Summary content', logger
|
||||
)
|
||||
store.apply_update(
|
||||
query, descriptor, 'conversation', 'external.thread_id', 'thread_abc', logger
|
||||
)
|
||||
|
||||
# Simulate runner returning new external.conversation_id
|
||||
store.apply_update(
|
||||
query, descriptor, 'conversation', 'external.conversation_id', 'conv_new_from_runner', logger
|
||||
)
|
||||
|
||||
# conversation.uuid is synced to new value
|
||||
assert conversation.uuid == 'conv_new_from_runner'
|
||||
|
||||
# Build new snapshot - previous state should still be accessible
|
||||
# because scope key is based on stable identity (create_time), not external uuid
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
|
||||
# All previously stored state should still be present
|
||||
assert snapshot['conversation']['memory.summary'] == 'Summary content'
|
||||
assert snapshot['conversation']['external.thread_id'] == 'thread_abc'
|
||||
assert snapshot['conversation']['external.conversation_id'] == 'conv_new_from_runner'
|
||||
|
||||
def test_conversation_scope_with_create_time_stability(self):
|
||||
"""Conversation scope key should use create_time for stability.
|
||||
|
||||
When create_time is available, it should be used as stable identity.
|
||||
Different conversations with same launcher but different create_time
|
||||
should have different scope keys.
|
||||
"""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
|
||||
# Two conversations with same launcher but different create_time
|
||||
conversation1 = FakeConversation(uuid='conv_1', create_time=10000)
|
||||
conversation2 = FakeConversation(uuid='conv_2', create_time=20000)
|
||||
session1 = FakeSession()
|
||||
session1.using_conversation = conversation1
|
||||
session2 = FakeSession()
|
||||
session2.using_conversation = conversation2
|
||||
|
||||
query1 = FakeQuery(session=session1)
|
||||
query2 = FakeQuery(session=session2)
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store for conversation1
|
||||
store.apply_update(query1, descriptor, 'conversation', 'key', 'value1', logger)
|
||||
|
||||
# conversation2 should not see conversation1's state (different create_time)
|
||||
# Note: snapshot2 may have seeded external.conversation_id from conversation2.uuid
|
||||
snapshot2 = store.build_snapshot(query2, descriptor)
|
||||
assert 'key' not in snapshot2['conversation'] # No state from conversation1
|
||||
|
||||
# conversation1 should see its own state
|
||||
snapshot1 = store.build_snapshot(query1, descriptor)
|
||||
assert snapshot1['conversation']['key'] == 'value1'
|
||||
|
||||
def test_conversation_scope_without_create_time_uses_launcher_identity(self):
|
||||
"""Conversation scope without create_time should use launcher identity.
|
||||
|
||||
When create_time is not available, scope key should be based on
|
||||
launcher (person/group) identity, assuming one active conversation
|
||||
per launcher context.
|
||||
"""
|
||||
store = RunnerScopedStateStore()
|
||||
descriptor = make_descriptor()
|
||||
|
||||
# Conversation without create_time
|
||||
conversation = FakeConversation(uuid='conv_1', create_time=None)
|
||||
session = FakeSession()
|
||||
session.using_conversation = conversation
|
||||
query = FakeQuery(session=session)
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store some state
|
||||
store.apply_update(query, descriptor, 'conversation', 'key', 'value', logger)
|
||||
|
||||
# State should be accessible
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
assert snapshot['conversation']['key'] == 'value'
|
||||
|
||||
# Update external.conversation_id
|
||||
store.apply_update(
|
||||
query, descriptor, 'conversation', 'external.conversation_id', 'conv_2', logger
|
||||
)
|
||||
|
||||
# State should still be accessible (scope key unchanged)
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
assert snapshot['conversation']['key'] == 'value'
|
||||
assert snapshot['conversation']['external.conversation_id'] == 'conv_2'
|
||||
|
||||
|
||||
class TestStateStoreGlobalSingleton:
|
||||
"""Tests for global singleton functions."""
|
||||
|
||||
def test_get_state_store_returns_singleton(self):
|
||||
"""get_state_store should return the same instance."""
|
||||
reset_state_store()
|
||||
store1 = get_state_store()
|
||||
store2 = get_state_store()
|
||||
|
||||
assert store1 is store2
|
||||
|
||||
def test_reset_state_store_clears_singleton(self):
|
||||
"""reset_state_store should clear the singleton."""
|
||||
store1 = get_state_store()
|
||||
reset_state_store()
|
||||
store2 = get_state_store()
|
||||
|
||||
assert store1 is not store2
|
||||
|
||||
def test_reset_state_store_clears_data(self):
|
||||
"""reset_state_store should clear stored data."""
|
||||
store = get_state_store()
|
||||
descriptor = make_descriptor()
|
||||
query = FakeQuery()
|
||||
logger = FakeLogger()
|
||||
|
||||
# Store some data
|
||||
store.apply_update(query, descriptor, 'conversation', 'key', 'value', logger)
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
assert snapshot['conversation']['key'] == 'value'
|
||||
|
||||
# Reset
|
||||
reset_state_store()
|
||||
store = get_state_store()
|
||||
|
||||
# Data should be gone
|
||||
snapshot = store.build_snapshot(query, descriptor)
|
||||
assert snapshot['conversation'] == {}
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_valid_state_scopes(self):
|
||||
"""VALID_STATE_SCOPES should have four scopes."""
|
||||
assert VALID_STATE_SCOPES == ('conversation', 'actor', 'subject', 'runner')
|
||||
|
||||
def test_legacy_key_mapping(self):
|
||||
"""LEGACY_KEY_MAPPING should map conversation_id."""
|
||||
assert LEGACY_KEY_MAPPING == {'conversation_id': 'external.conversation_id'}
|
||||
Reference in New Issue
Block a user