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:
huanghuoguoguo
2026-05-11 21:45:26 +08:00
parent d6b8f48e73
commit dc82fb584a
23 changed files with 4438 additions and 677 deletions

View 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,
}

View 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

View 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'

File diff suppressed because it is too large Load Diff

View 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

View 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'}