refactor(agent-runner): simplify event-first entry path

This commit is contained in:
huanghuoguoguo
2026-06-03 17:33:47 +08:00
parent 4d0a2b117a
commit a850127893
32 changed files with 743 additions and 2653 deletions

View File

@@ -45,7 +45,7 @@ def make_session(
Args:
run_id: Unique run identifier
runner_id: Runner descriptor ID
query_id: Pipeline query ID
query_id: Host entry query ID
plugin_identity: Plugin identifier (author/name)
resources: AgentResources dict (uses make_resources() default if None)

View File

@@ -29,8 +29,9 @@ class MockLauncherType:
class MockConversation:
uuid = 'conv-uuid'
messages = []
def __init__(self):
self.uuid = 'conv-uuid'
self.messages = []
class MockMessage:
@@ -51,7 +52,9 @@ class MockAdapter:
class MockSession:
launcher_type = MockLauncherType()
launcher_id = 'user123'
using_conversation = MockConversation()
def __init__(self):
self.using_conversation = MockConversation()
class MockQuery:
@@ -155,6 +158,10 @@ class MockApplication:
self.model_mgr = MagicMock()
self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
# Mock sess_mgr
self.sess_mgr = MagicMock()
self.sess_mgr.get_conversation = AsyncMock()
class TestStreamingBehavior:
"""Tests for streaming mode behavior."""
@@ -232,7 +239,7 @@ class TestConfigMigrationInChatHandler:
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_runner_id_from_old_format(self):
"""ConfigMigration should handle old runner format."""
"""ConfigMigration should not resolve removed runner aliases."""
pipeline_config = {
'ai': {
'runner': {
@@ -242,7 +249,7 @@ class TestConfigMigrationInChatHandler:
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id is None
class TestErrorHandling:
@@ -399,6 +406,50 @@ class TestChatHandlerAsyncBehavior:
assert query.resp_messages[0].content == 'Response 1'
assert query.resp_messages[1].content == 'Response 2'
@pytest.mark.asyncio
async def test_history_update_recreates_conversation_if_tool_resets_it(self):
"""History update should tolerate CREATE_NEW_CONVERSATION during runner execution."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
response = MockMessageChunk('Tool response')
new_conversation = MockConversation()
class ResetConversationOrchestrator(MockAgentRunOrchestrator):
async def run_from_query(self, query):
query.session.using_conversation = None
yield response
mock_ap = MockApplication(orchestrator=ResetConversationOrchestrator())
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
mock_ap.sess_mgr.get_conversation = AsyncMock(return_value=new_conversation)
query = MockQuery()
query.adapter.is_stream = False
handler = ChatMessageHandler(mock_ap)
mock_event = MagicMock()
mock_event.return_value = MagicMock()
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
mock_ap.sess_mgr.get_conversation.assert_awaited_once()
assert query.session.using_conversation is new_conversation
assert new_conversation.messages == [query.user_message, response]
@pytest.mark.asyncio
async def test_runner_not_found_error(self):
"""Handler should catch RunnerNotFoundError and return INTERRUPT."""
@@ -550,4 +601,4 @@ class TestChatHandlerAsyncBehavior:
# Should return CONTINUE with reply message
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert len(query.resp_messages) == 1

View File

@@ -1,56 +1,14 @@
"""Tests for agent runner config migration."""
"""Tests for current AgentRunner config helpers."""
from __future__ import annotations
from langbot.pkg.agent.runner.config_migration import (
ConfigMigration,
OLD_RUNNER_TO_PLUGIN_RUNNER_ID,
)
class TestOldRunnerMapping:
"""Tests for OLD_RUNNER_TO_PLUGIN_RUNNER_ID mapping."""
def test_local_agent_mapping(self):
"""Local-agent should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent'] == 'plugin:langbot/local-agent/default'
def test_dify_mapping(self):
"""Dify should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['dify-service-api'] == 'plugin:langbot/dify-agent/default'
def test_n8n_mapping(self):
"""n8n should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['n8n-service-api'] == 'plugin:langbot/n8n-agent/default'
def test_coze_mapping(self):
"""Coze should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['coze-api'] == 'plugin:langbot/coze-agent/default'
def test_all_runners_mapped(self):
"""All old runners should have mapping."""
expected_runners = [
'local-agent',
'dify-service-api',
'n8n-service-api',
'coze-api',
'dashscope-app-api',
'langflow-api',
'tbox-app-api',
]
for runner in expected_runners:
assert runner in OLD_RUNNER_TO_PLUGIN_RUNNER_ID
mapped = OLD_RUNNER_TO_PLUGIN_RUNNER_ID[runner]
assert mapped.startswith('plugin:langbot/')
assert mapped.endswith('/default')
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestResolveRunnerId:
"""Tests for ConfigMigration.resolve_runner_id."""
def test_resolve_new_format_runner_id(self):
"""Resolve runner ID from new format."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
@@ -62,8 +20,7 @@ class TestResolveRunnerId:
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_old_format_runner_name(self):
"""Resolve runner ID from old format."""
def test_does_not_resolve_old_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
@@ -72,49 +29,18 @@ class TestResolveRunnerId:
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_old_format_plugin_runner(self):
"""Resolve already migrated plugin:* runner."""
pipeline_config = {
'ai': {
'runner': {
'runner': 'plugin:alice/my-agent/custom',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:alice/my-agent/custom'
def test_resolve_no_runner_config(self):
"""Resolve runner ID when not configured."""
pipeline_config = {}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None
def test_resolve_priority_new_over_old(self):
"""New format takes priority over old format."""
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'runner': 'dify-service-api', # This should be ignored
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_no_runner_config(self):
runner_id = ConfigMigration.resolve_runner_id({})
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for ConfigMigration.resolve_runner_config."""
def test_resolve_new_format_config(self):
"""Resolve runner config from new format."""
def test_resolve_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
@@ -132,13 +58,11 @@ class TestResolveRunnerConfig:
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_resolve_old_format_config(self):
"""Runtime config resolver should not read old format."""
def test_does_not_read_old_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
'custom_option': 10,
},
},
}
@@ -149,62 +73,18 @@ class TestResolveRunnerConfig:
)
assert config == {}
def test_resolve_legacy_config_for_migration(self):
"""Migration helper should read old format."""
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
'custom_option': 10,
'knowledge-base': 'kb-123',
},
},
}
config = ConfigMigration.resolve_legacy_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10, 'knowledge-bases': ['kb-123']}
assert 'knowledge-base' not in config
def test_resolve_no_config(self):
"""Resolve runner config when not found."""
pipeline_config = {}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
{},
'plugin:langbot/local-agent/default',
)
assert config == {}
def test_resolve_priority_new_over_old(self):
"""New format config takes priority."""
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {
'model': 'new-uuid',
},
},
'local-agent': {
'model': 'old-uuid',
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'new-uuid'}
class TestGetExpireTime:
"""Tests for ConfigMigration.get_expire_time."""
def test_get_expire_time_zero(self):
"""Get expire time when zero."""
pipeline_config = {
'ai': {
'runner': {
@@ -217,7 +97,6 @@ class TestGetExpireTime:
assert expire_time == 0
def test_get_expire_time_positive(self):
"""Get expire time when positive."""
pipeline_config = {
'ai': {
'runner': {
@@ -230,22 +109,44 @@ class TestGetExpireTime:
assert expire_time == 3600
def test_get_expire_time_default(self):
"""Get expire time when not configured."""
pipeline_config = {}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
expire_time = ConfigMigration.get_expire_time({})
assert expire_time == 0
class TestGetOldRunnerName:
"""Tests for ConfigMigration.get_old_runner_name."""
class TestNormalizePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config."""
def test_get_old_runner_name_mapped(self):
"""Get old runner name for mapped runner ID."""
old_name = ConfigMigration.get_old_runner_name('plugin:langbot/local-agent/default')
assert old_name == 'local-agent'
def test_normalizes_current_containers(self):
config = {'ai': {}}
def test_get_old_runner_name_not_mapped(self):
"""Get old runner name for unmapped runner ID."""
old_name = ConfigMigration.get_old_runner_name('plugin:alice/my-agent/custom')
assert old_name is None
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {'ai': {'runner': {}, 'runner_config': {}}}
def test_preserves_current_config(self):
config = {
'ai': {
'runner': {'id': 'plugin:test/my-runner/default'},
'runner_config': {
'plugin:test/my-runner/default': {'custom-option': 20},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default'
assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20
def test_does_not_migrate_old_runner_blocks(self):
config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': 'old-model'},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert 'id' not in migrated['ai']['runner']
assert migrated['ai']['local-agent'] == {'model': 'old-model'}

View File

@@ -1,4 +1,4 @@
"""Tests for pipeline config migration to new runner format."""
"""Tests for persisted AgentRunner config shape."""
from __future__ import annotations
@@ -10,62 +10,8 @@ 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': []},
'knowledge-base': 'kb-uuid',
'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']['knowledge-bases'] == ['kb-uuid']
assert 'knowledge-base' not in migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
# 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 = {
def test_current_format_config_stays_unchanged(self):
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
@@ -80,134 +26,67 @@ class TestMigratePipelineConfig:
},
}
migrated = ConfigMigration.migrate_pipeline_config(new_config)
migrated = ConfigMigration.migrate_pipeline_config(config)
# Should remain unchanged
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10
def test_new_format_local_agent_config_normalizes_legacy_kb_key(self):
"""Migration should normalize legacy KB aliases before runtime."""
def test_old_runner_field_is_not_mapped(self):
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb-legacy',
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
runner_config = migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
assert runner_config == {'knowledge-bases': ['kb-legacy']}
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': {},
'local-agent': {
'model': 'old-model',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(old_config)
assert migrated['ai']['runner']['expire-time'] == 3600
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner'] == {
'runner': 'local-agent',
'expire-time': 3600,
}
assert migrated['ai']['runner_config'] == {}
assert migrated['ai']['local-agent'] == {'model': 'old-model'}
def test_empty_config_is_unchanged(self):
config = {}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {}
def test_config_without_ai_section_is_unchanged(self):
config = {'trigger': {}}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {'trigger': {}}
class TestDefaultPipelineConfig:
"""Tests for default-pipeline-config.json format."""
def test_default_config_is_new_format(self):
"""Default pipeline template should use the new runner config shape."""
def test_default_config_is_current_format(self):
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 runner selection and config defaults are rendered at creation
# time from installed AgentRunner metadata.
assert 'runner_config' in config['ai']
assert config['ai']['runner_config'] == {}
# Should NOT have old local-agent key
assert 'local-agent' not in config['ai']
def test_default_config_does_not_hardcode_plugin_schema(self):
"""Default template should not duplicate plugin-provided config schema."""
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)
class TestResolveRunnerId:
"""Tests for current runner id resolution."""
assert config['ai']['runner_config'] == {}
class TestResolveRunnerIdAliases:
"""Tests for runner id alias resolution."""
def test_resolve_new_format_id(self):
"""resolve_runner_id should work with new format."""
def test_resolve_current_id(self):
config = {
'ai': {
'runner': {'id': 'plugin:test/my-runner/default'},
@@ -216,45 +95,20 @@ class TestResolveRunnerIdAliases:
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."""
def test_old_runner_field_is_ignored(self):
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'
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for runtime runner config resolution."""
def test_resolve_new_format_config(self):
"""resolve_runner_config should read from runner_config."""
def test_resolve_current_config(self):
config = {
'ai': {
'runner_config': {
@@ -265,8 +119,7 @@ class TestResolveRunnerConfig:
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['custom-option'] == 20
def test_resolve_old_format_config(self):
"""resolve_runner_config should not read old ai.local-agent at runtime."""
def test_old_runner_block_is_ignored(self):
config = {
'ai': {
'local-agent': {'custom-option': 20},
@@ -274,26 +127,3 @@ class TestResolveRunnerConfig:
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {}
def test_resolve_legacy_runner_config_for_migration(self):
"""resolve_legacy_runner_config should read old ai.local-agent for migration."""
config = {
'ai': {
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_legacy_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {'custom-option': 20}
def test_resolve_new_format_priority(self):
"""New format runner_config should take priority."""
config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {'custom-option': 25},
},
'local-agent': {'custom-option': 10}, # Old, should be ignored
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['custom-option'] == 25

View File

@@ -1,31 +1,15 @@
"""Tests for Pipeline adapter params and prompt packaging."""
"""Tests for Query entry adapter params packaging."""
from __future__ import annotations
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
class FakeMessage:
"""Fake prompt/history message."""
def __init__(self, content='Hello'):
self.content = content
self.role = 'user'
def model_dump(self, mode='json'):
return {'role': self.role, 'content': self.content}
class FakePrompt:
"""Fake prompt container."""
def __init__(self, messages=None):
self.messages = messages or []
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
class TestBuildParams:
"""Tests for PipelineAdapter.build_params filtering."""
"""Tests for QueryEntryAdapter.build_params filtering."""
def test_params_empty_when_no_variables(self):
query = type('Query', (), {'variables': None})()
assert PipelineAdapter.build_params(query) == {}
assert QueryEntryAdapter.build_params(query) == {}
def test_params_filters_underscore_prefix(self):
query = type('Query', (), {
@@ -37,7 +21,7 @@ class TestBuildParams:
},
})()
params = PipelineAdapter.build_params(query)
params = QueryEntryAdapter.build_params(query)
assert '_internal_var' not in params
assert '_pipeline_bound_plugins' not in params
assert '_monitoring_bot_name' not in params
@@ -61,7 +45,7 @@ class TestBuildParams:
},
})()
params = PipelineAdapter.build_params(query)
params = QueryEntryAdapter.build_params(query)
assert 'api_key' not in params
assert 'API_KEY' not in params
assert 'token' not in params
@@ -89,7 +73,7 @@ class TestBuildParams:
},
})()
params = PipelineAdapter.build_params(query)
params = QueryEntryAdapter.build_params(query)
assert params['launcher_type'] == 'telegram'
assert params['launcher_id'] == 'group_123'
assert params['sender_id'] == 'user_001'
@@ -116,7 +100,7 @@ class TestBuildParams:
},
})()
params = PipelineAdapter.build_params(query)
params = QueryEntryAdapter.build_params(query)
assert 'string_value' in params
assert 'int_value' in params
assert 'float_value' in params
@@ -139,41 +123,40 @@ class TestBuildParams:
},
})()
params = PipelineAdapter.build_params(query)
params = QueryEntryAdapter.build_params(query)
assert 'nested_list_with_bad' not in params
assert 'nested_dict_with_bad' not in params
assert 'good_nested_list' in params
assert 'good_nested_dict' in params
def test_is_json_serializable_primitives_and_collections(self):
assert PipelineAdapter.is_json_serializable(None) is True
assert PipelineAdapter.is_json_serializable('string') is True
assert PipelineAdapter.is_json_serializable(42) is True
assert PipelineAdapter.is_json_serializable(['a', 'b']) is True
assert PipelineAdapter.is_json_serializable({'key': 'value'}) is True
assert PipelineAdapter.is_json_serializable((1, 2, 3)) is True
assert QueryEntryAdapter.is_json_serializable(None) is True
assert QueryEntryAdapter.is_json_serializable('string') is True
assert QueryEntryAdapter.is_json_serializable(42) is True
assert QueryEntryAdapter.is_json_serializable(['a', 'b']) is True
assert QueryEntryAdapter.is_json_serializable({'key': 'value'}) is True
assert QueryEntryAdapter.is_json_serializable((1, 2, 3)) is True
def test_is_json_serializable_rejects_sets_and_objects(self):
class CustomObject:
pass
assert PipelineAdapter.is_json_serializable(CustomObject()) is False
assert PipelineAdapter.is_json_serializable({1, 2, 3}) is False
assert PipelineAdapter.is_json_serializable([1, {2, 3}]) is False
assert PipelineAdapter.is_json_serializable({'key': {1, 2}}) is False
assert QueryEntryAdapter.is_json_serializable(CustomObject()) is False
assert QueryEntryAdapter.is_json_serializable({1, 2, 3}) is False
assert QueryEntryAdapter.is_json_serializable([1, {2, 3}]) is False
assert QueryEntryAdapter.is_json_serializable({'key': {1, 2}}) is False
class TestBuildPrompt:
"""Tests for PipelineAdapter.build_prompt."""
class TestBuildAdapterContext:
"""Tests for QueryEntryAdapter.build_adapter_context."""
def test_prompt_empty_when_missing(self):
query = type('Query', (), {})()
assert PipelineAdapter.build_prompt(query) == []
def test_prompt_serializes_messages(self):
def test_adapter_context_does_not_push_prompt(self):
query = type('Query', (), {
'prompt': FakePrompt([FakeMessage('Effective prompt')]),
'variables': {},
'query_id': 123,
'prompt': object(),
})()
prompt = PipelineAdapter.build_prompt(query)
assert prompt == [{'role': 'user', 'content': 'Effective prompt'}]
context = QueryEntryAdapter.build_adapter_context(query, binding=None)
assert context == {'params': {}, 'query_id': 123}

View File

@@ -259,7 +259,7 @@ class TestContextValidation:
# Protocol v1 DOES have these
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1"
assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1"
assert 'bootstrap' in context_dict, "bootstrap should exist (can be None)"
assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows"
assert 'adapter' in context_dict, "adapter should exist"
assert 'metadata' in context_dict, "metadata should exist"

View File

@@ -1,8 +1,8 @@
"""Tests for event-first Protocol v1 entities and Pipeline adapter.
"""Tests for event-first Protocol v1 entities and Query entry adapter.
Tests cover:
1. Pipeline Query -> AgentEventEnvelope conversion
2. Pipeline config -> AgentBinding conversion
1. Query -> AgentEventEnvelope conversion
2. Current config -> AgentBinding conversion
3. AgentRunContext not inlining full history by default
4. LangBot Host not defining context-window controls
5. Event-first run() entry point
@@ -31,32 +31,32 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
)
# Import LangBot host models
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
class TestPipelineQueryToEventEnvelope:
"""Test Pipeline Query -> AgentEventEnvelope conversion."""
class TestQueryToEventEnvelope:
"""Test Query -> AgentEventEnvelope conversion."""
def test_query_to_event_basic_fields(self, mock_query):
"""Test basic field conversion from Query to Event envelope."""
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.event_type == "message.received"
assert event.source == "pipeline_adapter"
assert event.source == "host_adapter"
assert event.bot_id == mock_query.bot_uuid
assert event.actor is not None
assert event.actor.actor_type == "user"
def test_query_to_event_input(self, mock_query):
"""Test input conversion from Query."""
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.input is not None
assert event.input.text == "Hello world"
def test_query_to_event_conversation(self, mock_query):
"""Test conversation context extraction."""
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-uuid-123"
@@ -65,7 +65,7 @@ class TestPipelineQueryToEventEnvelope:
mock_query.session.using_conversation.uuid = None
mock_query.variables["conversation_id"] = "conv-from-vars"
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-from-vars"
@@ -73,13 +73,13 @@ class TestPipelineQueryToEventEnvelope:
"""Debug Chat and legacy pipeline runs may not have a conversation UUID."""
mock_query.session.using_conversation.uuid = None
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "person_launcher-123"
def test_query_to_event_delivery_context(self, mock_query):
"""Test delivery context extraction."""
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery is not None
assert event.delivery.surface == "platform"
@@ -98,7 +98,7 @@ class TestPipelineQueryToEventEnvelope:
})
mock_query.message_event = source_event
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.source_event_type == "platform.message.created"
assert event.event_time == 1700000000
@@ -111,28 +111,28 @@ class TestPipelineQueryToEventEnvelope:
"""Test delivery context building when Query has no message_chain."""
delattr(mock_query, "message_chain")
event = PipelineAdapter.query_to_event(mock_query)
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery.reply_target == {"message_id": None}
def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query):
"""Pipeline-local message IDs must not become global audit IDs."""
first = PipelineAdapter.query_to_event(mock_query)
first = QueryEntryAdapter.query_to_event(mock_query)
mock_query.launcher_id = "launcher-456"
second = PipelineAdapter.query_to_event(mock_query)
second = QueryEntryAdapter.query_to_event(mock_query)
assert first.event_id.startswith("pipeline:")
assert first.event_id.startswith("host:")
assert first.event_id != "789"
assert second.event_id != first.event_id
class TestPipelineConfigToBinding:
"""Test Pipeline config -> AgentBinding conversion."""
class TestQueryConfigToBinding:
"""Test current config -> AgentBinding conversion."""
def test_config_to_binding_runner_id(self, mock_query):
"""Test binding runner_id extraction."""
binding = PipelineAdapter.pipeline_config_to_binding(
binding = QueryEntryAdapter.config_to_binding(
mock_query, "plugin:author/plugin/runner"
)
@@ -140,7 +140,7 @@ class TestPipelineConfigToBinding:
def test_config_to_binding_scope(self, mock_query):
"""Test binding scope extraction."""
binding = PipelineAdapter.pipeline_config_to_binding(
binding = QueryEntryAdapter.config_to_binding(
mock_query, "plugin:test/plugin/runner"
)
@@ -177,8 +177,8 @@ class TestAgentRunContextProtocolV1:
assert ctx.event is not None
assert ctx.event.event_type == "message.received"
def test_sdk_context_messages_default_empty(self):
"""Test that messages default to empty (not full history)."""
def test_sdk_context_has_no_history_message_fields(self):
"""AgentRunContext should not expose inline history message fields."""
trigger = AgentTrigger(type="message.received")
event = AgentEventContext(
event_id="evt_1",
@@ -200,34 +200,9 @@ class TestAgentRunContextProtocolV1:
runtime=AgentRuntimeContext(),
)
# messages is now in bootstrap, not top-level
assert ctx.bootstrap is None or ctx.bootstrap.messages == []
def test_sdk_context_bootstrap_optional(self):
"""Test that bootstrap is optional."""
trigger = AgentTrigger(type="message.received")
event = AgentEventContext(
event_id="evt_1",
event_type="message.received",
source="platform",
)
input = AgentInput(text="Hello")
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
ctx = AgentRunContext(
run_id="run_1",
trigger=trigger,
event=event,
input=input,
delivery=DeliveryContext(surface="platform"),
resources=AgentResources(),
runtime=AgentRuntimeContext(),
)
# bootstrap is optional
assert ctx.bootstrap is None or isinstance(ctx.bootstrap.messages, list)
assert "messages" not in AgentRunContext.model_fields
assert "bootstrap" not in AgentRunContext.model_fields
assert not hasattr(ctx, "bootstrap")
class TestHostManagedHistoryNotInProtocol:
@@ -306,7 +281,7 @@ class TestSDKResultProtocolV1:
# Fixtures
@pytest.fixture
def mock_query():
"""Create a mock Pipeline Query for testing."""
"""Create a mock query for testing."""
query = Mock()
query.query_id = 123
query.bot_uuid = "bot-uuid-123"

View File

@@ -576,11 +576,10 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
assert 'kb_custom' in allowed_kbs
def test_retrieve_kb_fix_old_format(self):
"""Fix should work for old format pipeline config."""
def test_retrieve_kb_ignores_old_runner_format(self):
"""Old runner format is not resolved by current AgentRunner helpers."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
# Old format: ai.runner.runner = 'local-agent'
pipeline_config = {
'ai': {
'runner': {
@@ -590,31 +589,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
# Should resolve to plugin:langbot/local-agent/default
assert 'local-agent' in runner_id
def test_retrieve_kb_legacy_single_key_is_migration_only(self):
"""Old singular knowledge-base config is normalized before runtime."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb_single', # Old singular field
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(pipeline_config)
runner_id = ConfigMigration.resolve_runner_id(migrated)
runner_config = ConfigMigration.resolve_runner_config(migrated, runner_id)
assert runner_config == {'knowledge-bases': ['kb_single']}
assert runner_id is None
class TestHandlerActionAuthorization:
@@ -850,7 +825,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
"""CALL_TOOL: SDK includes 'run_id' field."""
# SDK agent_run_api.py line 144: "run_id": self.run_id
# Host handler.py line 458: run_id = data.get('run_id')
sdk_fields = ['run_id', 'tool_name', 'parameters', 'session', 'query_id']
sdk_fields = ['run_id', 'tool_name', 'parameters']
host_expected_fields = ['tool_name', 'parameters', 'run_id']
for field in host_expected_fields:

View File

@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -239,7 +239,7 @@ def test_context_builder_includes_consumable_base64_attachments():
[platform_message.Image(base64="data:image/jpeg;base64,aGVsbG8=")]
)
input_data = PipelineAdapter._build_input(query)
input_data = QueryEntryAdapter._build_input(query)
assert input_data.contents[0].text == "see attached"
assert input_data.contents[1].image_base64 == "data:image/png;base64,aGVsbG8="
@@ -362,8 +362,8 @@ async def test_orchestrator_does_not_package_query_messages_into_context(clean_a
assert len(messages) == 1
context = plugin_connector.contexts[0]
assert context["config"]["custom-option"] == 2
assert context["bootstrap"] is None
assert set(context["adapter"]) == {"query_id", "extra"}
assert "bootstrap" not in context
assert set(context["adapter"]) == {"extra"}
assert "context_packaging" not in context["runtime"]["metadata"]
assert [message.content for message in query.messages] == [
"message 1",
@@ -473,12 +473,12 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
assert await get_session_registry().list_active_runs() == []
class TestPipelineCompatibilityQueryIdInSession:
"""Tests for query_id entering session registry."""
class TestQueryEntrySessionQueryId:
"""Tests for internal query_id entering session registry."""
@pytest.mark.asyncio
async def test_query_id_registered_in_session_for_pipeline_flow(self, clean_agent_state):
"""query_id from Pipeline flow is registered in session."""
async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
"""query_id from Query entry flow is registered internally in session."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
@@ -557,12 +557,12 @@ class TestPipelineCompatibilityQueryIdInSession:
assert session_during_run["query_id"] is None
class TestPipelineAdapterPromptAndParams:
"""Tests for prompt and params handling in Pipeline adapter."""
class TestQueryEntryAdapterParams:
"""Tests for params handling in Query entry adapter."""
@pytest.mark.asyncio
async def test_prompt_in_adapter_extra(self, clean_agent_state):
"""Pipeline prompt is placed in adapter.extra.prompt."""
async def test_prompt_not_pushed_into_adapter_extra(self, clean_agent_state):
"""Pipeline prompt is not pushed into adapter.extra."""
from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt
db_engine = clean_agent_state
@@ -590,12 +590,8 @@ class TestPipelineAdapterPromptAndParams:
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
# Prompt should be in adapter.extra
assert "prompt" in context["adapter"]["extra"]
assert len(context["adapter"]["extra"]["prompt"]) == 1
assert context["adapter"]["extra"]["prompt"][0]["role"] == "system"
# Top-level should NOT have prompt
assert "prompt" not in context
assert "prompt" not in context["adapter"]["extra"]
@pytest.mark.asyncio
async def test_params_filtering_keeps_public_param(self, clean_agent_state):
@@ -721,8 +717,8 @@ class TestPipelineAdapterPromptAndParams:
assert "a_lambda" not in params
class TestPipelineAdapterHostCapabilities:
"""Tests for event-first host capabilities via Pipeline adapter path."""
class TestQueryEntryAdapterHostCapabilities:
"""Tests for event-first host capabilities via Query entry adapter path."""
@pytest.mark.asyncio
async def test_state_updated_writes_to_persistent_store(self, clean_agent_state):
@@ -760,9 +756,9 @@ class TestPipelineAdapterHostCapabilities:
persistent_store = get_persistent_state_store(db_engine)
# Build snapshot to check if state was written
# Note: We need to rebuild the event and binding to query the store
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
event = PipelineAdapter.query_to_event(query)
binding = PipelineAdapter.pipeline_config_to_binding(query, RUNNER_ID)
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
event = QueryEntryAdapter.query_to_event(query)
binding = QueryEntryAdapter.config_to_binding(query, RUNNER_ID)
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot["conversation"]["external.test_key"] == "test_value"

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
@@ -62,7 +62,7 @@ def make_query(
async def build_resources(app, query, descriptor):
binding = PipelineAdapter.pipeline_config_to_binding(query, descriptor.id)
binding = QueryEntryAdapter.config_to_binding(query, descriptor.id)
return await AgentResourceBuilder(app).build_resources_from_binding(
event=Mock(),
binding=binding,