"""E2E tests for pluginized AgentRunner execution. This module starts the real LangBot backend with the plugin system enabled and loads a deterministic AgentRunner plugin through the real SDK Plugin Runtime. """ from __future__ import annotations import shutil import socket import sqlite3 import subprocess import sys import tempfile import textwrap import time from pathlib import Path import httpx import pytest from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories from tests.e2e.utils.process_manager import LangBotProcess, find_project_root pytestmark = pytest.mark.e2e QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default' @pytest.fixture(scope='session') def agent_runner_e2e_port(): """Port for the AgentRunner plugin-runtime E2E process.""" return 15310 @pytest.fixture(scope='session') def agent_runner_e2e_tmpdir(): """Create temporary directory for AgentRunner E2E testing.""" tmpdir = Path(tempfile.mkdtemp(prefix='langbot_agent_runner_e2e_')) yield tmpdir shutil.rmtree(tmpdir, ignore_errors=True) def _write_qa_agent_runner_plugin(plugin_root: Path) -> None: """Write a deterministic AgentRunner plugin used by this E2E.""" runner_dir = plugin_root / 'components' / 'agent_runner' runner_dir.mkdir(parents=True, exist_ok=True) (plugin_root / 'assets').mkdir(parents=True, exist_ok=True) (plugin_root / 'assets' / 'icon.svg').write_text( '', encoding='utf-8', ) (plugin_root / 'manifest.yaml').write_text( textwrap.dedent( """ apiVersion: langbot/v1 kind: Plugin metadata: author: e2e name: agent-runner-qa version: 0.1.0 label: en_US: AgentRunner QA zh_Hans: AgentRunner QA description: en_US: Deterministic AgentRunner E2E probe. zh_Hans: 确定性的 AgentRunner E2E 探针。 icon: assets/icon.svg spec: version: 0.1.0 config: [] components: AgentRunner: fromDirs: - path: components/agent_runner/ EventProcessor: fromDirs: - path: components/event_processor/ pages: [] execution: python: path: main.py attr: AgentRunnerQAPlugin """ ).strip() + '\n', encoding='utf-8', ) (plugin_root / 'main.py').write_text( textwrap.dedent( """ from __future__ import annotations from langbot_plugin.api.definition.plugin import BasePlugin class AgentRunnerQAPlugin(BasePlugin): async def initialize(self) -> None: pass """ ).strip() + '\n', encoding='utf-8', ) (runner_dir / 'default.yaml').write_text( textwrap.dedent( """ apiVersion: langbot/v1 kind: AgentRunner metadata: name: default label: en_US: QA Echo Runner zh_Hans: QA Echo Runner description: en_US: Echoes input and exercises run-scoped state APIs. zh_Hans: 回显输入并验证运行级状态 API。 spec: config: [] capabilities: streaming: false permissions: {} execution: python: path: default.py attr: DefaultAgentRunner """ ).strip() + '\n', encoding='utf-8', ) (runner_dir / 'default.py').write_text( textwrap.dedent( """ from __future__ import annotations from typing import AsyncGenerator from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult from langbot_plugin.api.entities.builtin.provider.message import Message class DefaultAgentRunner(AgentRunner): async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: text = ctx.input.to_text() yield AgentRunResult.message_completed( ctx.run_id, Message(role='assistant', content=f'e2e echo: {text}'), ) yield AgentRunResult.state_updated( ctx.run_id, 'e2e.echo_count', {'count': 1}, scope='conversation', ) yield AgentRunResult.run_completed(ctx.run_id, finish_reason='stop') """ ).strip() + '\n', encoding='utf-8', ) processor_dir = plugin_root / 'components' / 'event_processor' processor_dir.mkdir(parents=True) (processor_dir / 'default.yaml').write_text( textwrap.dedent(""" apiVersion: langbot/v1 kind: EventProcessor metadata: name: default label: {en_US: Welcome processor, zh_Hans: Welcome processor} spec: events: [group.member_joined] config: - name: greeting type: string required: true label: {en_US: Greeting, zh_Hans: Greeting} default: Hello capabilities: {tool_calling: true} permissions: tools: [detail, call] execution: python: {path: default.py, attr: WelcomeProcessor} """) ) (processor_dir / 'default.py').write_text( textwrap.dedent(""" from langbot_plugin.api.definition.components.event_processor import EventProcessor, EventProcessorContext from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent class WelcomeProcessor(EventProcessor): async def initialize(self): @self.handler(MemberJoinedEvent) async def handle(ctx: EventProcessorContext): await ctx.log('Handling ' + str(ctx.event.member.id)) result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.event.member.nickname or str(ctx.event.member.id))) await ctx.log('Reply simulated: ' + str(result.get('mock'))) """) ) def _free_port() -> int: """Reserve a currently-free localhost TCP port for this E2E process.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(('127.0.0.1', 0)) return int(sock.getsockname()[1]) @pytest.fixture(scope='session') def agent_runner_runtime_ports(): """Control/debug ports for the standalone plugin runtime.""" control_port = _free_port() debug_port = _free_port() while debug_port == control_port: debug_port = _free_port() return control_port, debug_port @pytest.fixture(scope='session') def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, agent_runner_runtime_ports): """Create a plugin-enabled config and deterministic AgentRunner fixture.""" config_path = create_minimal_config(agent_runner_e2e_tmpdir, port=agent_runner_e2e_port) create_test_directories(agent_runner_e2e_tmpdir) import yaml with open(config_path, encoding='utf-8') as f: config = yaml.safe_load(f) config['api']['global_api_key'] = 'e2e-agent-runner-key' runtime_control_port, _runtime_debug_port = agent_runner_runtime_ports config['plugin']['enable'] = True config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws' config['plugin']['enable_marketplace'] = False config['box']['enabled'] = False config['system']['jwt']['secret'] = 'e2e-agent-runner-secret-key' with open(config_path, 'w', encoding='utf-8') as f: yaml.safe_dump(config, f, default_flow_style=False) plugin_source = agent_runner_e2e_tmpdir / 'agent-runner-qa-package' _write_qa_agent_runner_plugin(plugin_source) shutil.make_archive( str(agent_runner_e2e_tmpdir / 'agent-runner-qa'), 'zip', root_dir=plugin_source, ) return config_path @pytest.fixture(scope='session') def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_ports): """Start the real SDK plugin runtime over WebSocket.""" control_port, debug_port = agent_runner_runtime_ports stdout_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log' stderr_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log' stdout_file = open(stdout_path, 'wb') stderr_file = open(stderr_path, 'wb') proc = subprocess.Popen( [ sys.executable, '-m', 'langbot_plugin.cli.__init__', 'rt', '--ws-control-port', str(control_port), '--ws-debug-port', str(debug_port), ], cwd=agent_runner_e2e_tmpdir, stdout=stdout_file, stderr=stderr_file, start_new_session=True, ) yield proc proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() proc.wait() stdout_file.close() stderr_file.close() @pytest.fixture(scope='session') def agent_runner_langbot_process( agent_runner_e2e_config_path, agent_runner_e2e_port, agent_runner_e2e_tmpdir, agent_runner_runtime_process, ): """Start real LangBot with plugin runtime enabled.""" project_root = find_project_root() proc = LangBotProcess( project_root=project_root, work_dir=agent_runner_e2e_tmpdir, port=agent_runner_e2e_port, timeout=180, debug=True, cli_args=['--standalone-runtime'], ) success = proc.start() if not success: stdout, stderr = proc.get_logs() pytest.fail(f'LangBot failed to start with AgentRunner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}') yield proc proc.stop() @pytest.fixture def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process): """HTTP client for the AgentRunner E2E backend.""" with httpx.Client( base_url=f'http://127.0.0.1:{agent_runner_e2e_port}', timeout=90.0, trust_env=False, ) as client: yield client def _init_and_auth(client: httpx.Client) -> str: """Initialize the test admin user and return a bearer token.""" credentials = {'user': 'admin@langbot.test', 'password': 'admin'} init_resp = client.post('/api/v1/user/init', json=credentials) assert init_resp.status_code == 200 assert init_resp.json()['code'] in [0, 1] auth_resp = client.post('/api/v1/user/auth', json=credentials) assert auth_resp.status_code == 200 payload = auth_resp.json() assert payload['code'] == 0 return payload['data']['token'] def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None: """Install the QA Runner through the same asynchronous local-upload API as the UI.""" headers = {'Authorization': f'Bearer {token}'} with package_path.open('rb') as package_file: response = client.post( '/api/v1/plugins/install/local', headers=headers, files={'file': ('agent-runner-qa.zip', package_file, 'application/zip')}, ) assert response.status_code == 200, response.text payload = response.json() assert payload['code'] == 0, payload task_id = payload['data']['task_id'] deadline = time.time() + 90 while time.time() < deadline: task_response = client.get(f'/api/v1/system/tasks/{task_id}', headers=headers) assert task_response.status_code == 200, task_response.text task_payload = task_response.json() assert task_payload['code'] == 0, task_payload task = task_payload['data'] if task['runtime']['done']: assert task['runtime']['exception'] is None, task assert task['task_context']['metadata']['progress_percent'] == 100 return time.sleep(1) raise AssertionError(f'Plugin installation task {task_id} did not complete') def _wait_for_qa_runner(client: httpx.Client, token: str, timeout: float = 60) -> set[str]: """Return the latest Runner option set, waiting for the QA Runner when needed.""" deadline = time.time() + timeout option_names: set[str] = set() while time.time() < deadline: response = client.get( '/api/v1/pipelines/_/metadata', headers={'Authorization': f'Bearer {token}'}, ) assert response.status_code == 200, response.text data = response.json() assert data['code'] == 0, data metadata_groups = data['data']['configs'] ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai') runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner') runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id') option_names = {option['name'] for option in runner_select['options']} if QA_RUNNER_ID in option_names: break time.sleep(1) return option_names def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None: if QA_RUNNER_ID in _wait_for_qa_runner(client, token, timeout=2): return _install_qa_plugin(client, token, package_path) def test_plugin_runtime_discovers_agent_runner( agent_runner_client, agent_runner_langbot_process, agent_runner_e2e_tmpdir, ): """Pipeline metadata should include the real runtime-discovered QA runner.""" token = _init_and_auth(agent_runner_client) _ensure_qa_plugin( agent_runner_client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip', ) option_names = _wait_for_qa_runner(agent_runner_client, token) if QA_RUNNER_ID in option_names: return host_stdout, host_stderr = agent_runner_langbot_process.get_logs() runtime_stdout = (agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text( encoding='utf-8', errors='replace' ) runtime_stderr = (agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text( encoding='utf-8', errors='replace' ) assert QA_RUNNER_ID in option_names, ( f'{QA_RUNNER_ID} was not discovered\n' f'Host stdout (tail):\n{host_stdout[-20_000:]}\nHost stderr (tail):\n{host_stderr[-20_000:]}\n' f'Runtime stdout (tail):\n{runtime_stdout[-20_000:]}\n' f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}' ) def test_host_orchestrator_runs_agent_runner_and_records_ledger( agent_runner_client, agent_runner_langbot_process, agent_runner_e2e_tmpdir, ): """Create/configure/debug an Agent through HTTP and persist Runner side effects.""" del agent_runner_langbot_process token = _init_and_auth(agent_runner_client) _ensure_qa_plugin( agent_runner_client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip', ) headers = {'Authorization': f'Bearer {token}'} create_response = agent_runner_client.post( '/api/v1/agents', headers=headers, json={ 'kind': 'agent', 'name': 'AgentRunner E2E Agent', 'description': 'Exercises the installed QA Runner.', 'emoji': 'QA', 'supported_event_patterns': ['message.*'], 'config': { 'runner': {'id': QA_RUNNER_ID}, 'runner_config': {QA_RUNNER_ID: {}}, 'allowed_platform_tools': ['event_reply', 'platform_get_user_info'], }, }, ) assert create_response.status_code == 200, create_response.text create_payload = create_response.json() assert create_payload['code'] == 0, create_payload agent_uuid = create_payload['data']['uuid'] get_response = agent_runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers) assert get_response.status_code == 200, get_response.text stored_agent = get_response.json()['data']['agent'] assert stored_agent['config']['allowed_platform_tools'] == [ 'event_reply', 'platform_get_user_info', ] debug_response = agent_runner_client.post( f'/api/v1/agents/{agent_uuid}/debug', headers=headers, json={ 'event_type': 'message.received', 'text': 'hello from orchestrator e2e', 'conversation_id': 'e2e-conversation', }, ) assert debug_response.status_code == 200, debug_response.text debug_payload = debug_response.json() assert debug_payload['code'] == 0, debug_payload result = debug_payload['data'] assert result['final_text'] == 'e2e echo: hello from orchestrator e2e' assert result['outputs'][0]['role'] == 'assistant' db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db' conn = sqlite3.connect(str(db_path)) try: run_row = conn.execute( 'SELECT status, runner_id FROM agent_run WHERE event_id = ?', (result['event_id'],), ).fetchone() assert run_row == ('completed', QA_RUNNER_ID) event_types = { row[0] for row in conn.execute( 'SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = ?)', (result['event_id'],), ).fetchall() } assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types) state_row = conn.execute( "SELECT value_json FROM agent_runner_state WHERE state_key = 'e2e.echo_count'" ).fetchone() assert state_row is not None assert '"count": 1' in state_row[0] finally: conn.close() def test_event_processor_real_runtime_logs_actions_and_instance_isolation( agent_runner_client, agent_runner_e2e_tmpdir, ): client = agent_runner_client token = _init_and_auth(client) _ensure_qa_plugin(client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip') headers = {'Authorization': f'Bearer {token}'} metadata_response = client.get('/api/v1/agents/_/metadata', headers=headers).json() assert metadata_response['code'] == 0, metadata_response metadata = metadata_response['data'] ref = 'event_processor:e2e/agent-runner-qa/default' assert any(item['id'] == ref for item in metadata['event_processors']), metadata assert ref not in _wait_for_qa_runner(client, token) created = [] for name in ('First', 'Second'): response = client.post( '/api/v1/agents', headers=headers, json={ 'kind': 'event_processor', 'name': name, 'component_ref': ref, 'parameters': {'greeting': name}, }, ).json() assert response['code'] == 0, response created.append(response['data']['uuid']) for processor_id in created: page = client.get(f'/api/v1/agents/{processor_id}/runs', headers=headers).json() assert page['data']['items'] == [], page result = client.post( f'/api/v1/agents/{created[0]}/debug', headers=headers, json={ 'event_type': 'group.member_joined', 'data': {'member': {'id': 'member-1', 'nickname': 'Tester'}, 'group': {'id': 'group-1'}}, }, ).json() assert result['code'] == 0, result logs = [event['data']['text'] for event in result['data']['execution_events'] if event['type'] == 'processor.log'] assert logs == ['Handling member-1', 'Reply simulated: True'], result actions = [event for event in result['data']['execution_events'] if event['type'] == 'tool.call.completed'] assert len(actions) == 1, result assert actions[0]['data']['tool_name'] == 'event_reply' assert actions[0]['data']['result']['mock'] is True assert result['data']['final_text'] == '', result page = client.get(f'/api/v1/agents/{created[0]}/runs', headers=headers).json()['data'] assert len(page['items']) == 1, page run = page['items'][0] assert run['status'] == 'completed', run assert run['metadata']['input_event']['member']['id'] == 'member-1' trace = client.get(f'/api/v1/agents/{created[0]}/runs/{run["run_id"]}/events', headers=headers).json() assert trace['code'] == 0, trace assert any(item['type'] == 'processor.log' for item in trace['data']['items']), trace foreign = client.get(f'/api/v1/agents/{created[1]}/runs/{run["run_id"]}/events', headers=headers) assert foreign.status_code == 400 assert client.get(f'/api/v1/agents/{created[1]}/runs', headers=headers).json()['data']['items'] == []