From 6189b06dfc37fcdc6174fa862edb54a35ecaa932 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:16:55 +0000 Subject: [PATCH] fix(runner): align SDK pin and workspace-aware integration fixtures --- pyproject.toml | 2 +- tests/e2e/test_local_runner_fake_provider.py | 73 +++++++++-- .../agent/test_local_runner_e2e_fixtures.py | 123 ++++++++++++++++++ uv.lock | 4 +- 4 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 tests/unit_tests/agent/test_local_runner_e2e_fixtures.py diff --git a/pyproject.toml b/pyproject.toml index da87f64a6..629a450e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,4 +232,4 @@ line-ending = "auto" [tool.uv.sources] # Development contract: update to the matching SDK release before publishing. -langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "eac2c60509534512f9a373cd0c801e75985e0612" } +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index 042cfc63f..e30cb3c2b 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path: package_source = tmpdir / 'local-agent-package' ignore = shutil.ignore_patterns( '.git', - '.venv', + '.venv*', '__pycache__', '.pytest_cache', '.ruff_cache', @@ -181,7 +181,20 @@ class _FakeToolManager: def __init__(self): self.calls: list[dict[str, Any]] = [] - async def get_tool_schema(self, tool_name: str): + async def get_resolved_tool_catalog( + self, + context, + bound_plugins=None, + bound_mcp_servers=None, + include_skill_authoring=True, + include_mcp_resource_tools=False, + ): + assert context.workspace_uuid + return [{'name': E2E_TOOL_NAME, 'source': 'native', 'source_id': None}] + + async def get_tool_schema(self, context, tool_name: str, source_ref=None): + assert context.workspace_uuid + assert source_ref == {'source': 'native', 'source_id': None} if tool_name != E2E_TOOL_NAME: return None, None return ( @@ -195,14 +208,15 @@ class _FakeToolManager: }, ) - async def get_tool_detail(self, tool_name: str): - description, parameters = await self.get_tool_schema(tool_name) + async def get_tool_detail(self, context, tool_name: str, source_ref=None): + description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref) if parameters is None: return None return {'name': tool_name, 'description': description, 'parameters': parameters} - async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None): - del query + async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None): + assert query.workspace_uuid + assert source_ref == {'source': 'native', 'source_id': None} self.calls.append({'name': name, 'parameters': dict(parameters)}) return { 'value': f'tool-result:{parameters.get("query")}', @@ -223,7 +237,8 @@ class _FakeKnowledgeBase: def get_name(self) -> str: return 'E2E Fake KB' - async def retrieve(self, query_text: str, settings: dict[str, Any]): + async def retrieve(self, context, query_text: str, settings: dict[str, Any]): + assert context.workspace_uuid self.retrieve_calls.append({'query_text': query_text, 'settings': settings}) return [ SimpleNamespace( @@ -248,7 +263,8 @@ class _FakeRagManager: self.kb = kb self.knowledge_bases = {E2E_KB_UUID: kb} - async def get_knowledge_base_by_uuid(self, kb_uuid: str): + async def get_knowledge_base_by_uuid(self, context, kb_uuid: str): + assert context.workspace_uuid if kb_uuid == E2E_KB_UUID: return self.kb return None @@ -443,10 +459,27 @@ def _scripted_tool_call( async def _boot_local_agent_app(tmpdir: Path): """Boot LangBot and wait until the Local Agent runner is discoverable.""" from langbot.pkg.core import boot - from langbot_plugin.runtime.plugin.mgr import PluginInstallSource ap = await boot.make_app(asyncio.get_running_loop()) run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app') + try: + await _wait_for_local_agent_runner(ap, tmpdir) + except BaseException: + # The caller has not received ap yet. Close it here so a boot failure + # cannot reconnect after the probe restores the global transport mode. + try: + await ap.shutdown() + finally: + run_task.cancel() + await asyncio.gather(run_task, return_exceptions=True) + raise + return ap, run_task + + +async def _wait_for_local_agent_runner(ap, tmpdir: Path): + """Install and discover the runner on the application-owned connection.""" + from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + for _ in range(60): handler = getattr(ap.plugin_connector, 'handler', None) if handler is not None: @@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path): else: raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation') - return ap, run_task - def _run_local_agent_probe(tmpdir: Path, probe): """Run one Local Agent probe inside the temporary LangBot app.""" @@ -689,7 +720,13 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action( assert retrieve_calls == [ { 'query_text': 'Answer with the retrieved RAG sentinel.', - 'settings': {'top_k': 1, 'filters': {}}, + 'settings': { + 'top_k': 1, + 'filters': {}, + 'session_name': 'person_e2e-local-agent-rag-conversation', + 'bot_uuid': '', + 'sender_id': 'user-001', + }, } ] assert any( @@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint( ) store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + execution_context = await ap.plugin_connector._current_execution_context() for index in range(12): await store.append_transcript( transcript_id=None, event_id=f'e2e-local-agent-history-{index}', conversation_id='e2e-local-agent-compaction-conversation', + workspace_id=execution_context.workspace_uuid, role='user' if index % 2 == 0 else 'assistant', content=( f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10 @@ -847,11 +886,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( ap.rag_mgr = _FakeRagManager(fake_kb) store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + execution_context = await ap.plugin_connector._current_execution_context() for index in range(16): await store.append_transcript( transcript_id=None, event_id=f'e2e-local-agent-combo-history-{index}', conversation_id='e2e-local-agent-combo-conversation', + workspace_id=execution_context.workspace_uuid, role='user' if index % 2 == 0 else 'assistant', content=( f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL ' @@ -907,7 +948,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( assert retrieve_calls == [ { 'query_text': 'current combo request must survive; use RAG and tools before answering.', - 'settings': {'top_k': 1, 'filters': {}}, + 'settings': { + 'top_k': 1, + 'filters': {}, + 'session_name': 'person_e2e-local-agent-combo-conversation', + 'bot_uuid': '', + 'sender_id': 'user-001', + }, } ] assert invoke_count >= 4 diff --git a/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py new file mode 100644 index 000000000..46225fe20 --- /dev/null +++ b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py @@ -0,0 +1,123 @@ +"""Keep E2E fake resources compatible with the real Host contract.""" + +from types import SimpleNamespace +from unittest.mock import Mock +import pytest +from langbot.pkg.agent.runner.descriptor import RunnerDescriptor +from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder +from langbot.pkg.api.http.context import ExecutionContext +from tests.e2e.test_local_runner_fake_provider import ( + E2E_KB_UUID, + E2E_TOOL_NAME, + LOCAL_RUNNER_ID, + _FakeKnowledgeBase, + _FakeRagManager, + _FakeToolManager, + _binding, + _event, +) + +CONTEXT = ExecutionContext(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) +SOURCE = {'source': 'native', 'source_id': None} + + +async def _resources(tool_mgr, rag_mgr, binding): + descriptor = RunnerDescriptor( + id=LOCAL_RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + capabilities={'tool_calling': True, 'knowledge_retrieval': True}, + permissions={'tools': ['detail', 'call'], 'knowledge_bases': ['list', 'retrieve']}, + ) + app = SimpleNamespace(logger=Mock(), skill_mgr=None, tool_mgr=tool_mgr, rag_mgr=rag_mgr) + return await AgentResourceBuilder(app).build_resources_from_binding( + CONTEXT, + _event(event_id='evt', conversation_id='conv', text='test'), + binding, + descriptor, + ) + + +@pytest.mark.asyncio +async def test_fake_tool_is_projected_with_frozen_source_and_executed(): + manager = _FakeToolManager() + resources = await _resources( + manager, _FakeRagManager(_FakeKnowledgeBase()), _binding(allowed_tool_names=[E2E_TOOL_NAME]) + ) + assert len(resources['tools']) == 1 + tool = resources['tools'][0] + assert tool['tool_name'] == E2E_TOOL_NAME + assert {key: tool[key] for key in SOURCE} == SOURCE + assert tool['parameters']['required'] == ['query'] + detail = await manager.get_tool_detail(CONTEXT, E2E_TOOL_NAME, source_ref=SOURCE) + assert detail['parameters'] == tool['parameters'] + result = await manager.execute_func_call( + name=E2E_TOOL_NAME, + parameters={'query': 'alpha'}, + query=SimpleNamespace(workspace_uuid=CONTEXT.workspace_uuid), + source_ref=SOURCE, + ) + assert result['value'] == 'tool-result:alpha' + assert manager.calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}] + + +@pytest.mark.asyncio +async def test_fake_kb_is_projected_and_retrieved_with_execution_context(): + kb = _FakeKnowledgeBase() + manager = _FakeRagManager(kb) + resources = await _resources(_FakeToolManager(), manager, _binding(allowed_kb_uuids=[E2E_KB_UUID])) + assert [item['kb_id'] for item in resources['knowledge_bases']] == [E2E_KB_UUID] + resolved = await manager.get_knowledge_base_by_uuid(CONTEXT, E2E_KB_UUID) + entries = await resolved.retrieve(CONTEXT, 'test', settings={'top_k': 1, 'filters': {}}) + assert 'RAG_SENTINEL' in entries[0].content + assert kb.retrieve_calls == [{'query_text': 'test', 'settings': {'top_k': 1, 'filters': {}}}] + + +def test_local_agent_package_excludes_alternate_virtualenvs(tmp_path, monkeypatch): + import zipfile + from tests.e2e import test_local_runner_fake_provider as fixtures + + source = tmp_path / 'source' + source.mkdir() + (source / 'manifest.yaml').write_text('metadata: {author: langbot-team, name: LocalAgent}') + for name in ('.venv', '.venv311'): + (source / name).mkdir() + (source / name / 'not-plugin.py').write_text('pass') + monkeypatch.setattr(fixtures, '_local_agent_repo', lambda: source) + package = fixtures._package_local_agent_plugin(tmp_path / 'package') + with zipfile.ZipFile(package) as archive: + assert archive.namelist() == ['manifest.yaml'] + + +@pytest.mark.asyncio +async def test_failed_local_agent_boot_shuts_down_before_restoring_transport(monkeypatch, tmp_path): + import asyncio + from unittest.mock import AsyncMock + from langbot.pkg.core import boot + from tests.e2e import test_local_runner_fake_provider as fixtures + + async def running(): + await asyncio.Event().wait() + + app = SimpleNamespace( + run=running, + shutdown=AsyncMock(), + plugin_connector=SimpleNamespace( + handler=SimpleNamespace(ping=AsyncMock()), _current_execution_context=AsyncMock(return_value=CONTEXT) + ), + runner_registry=SimpleNamespace(list_runners=AsyncMock(side_effect=ValueError('probe boot failure'))), + ) + monkeypatch.setattr(boot, 'make_app', AsyncMock(return_value=app)) + try: + with pytest.raises(ValueError, match='probe boot failure'): + await fixtures._boot_local_agent_app(tmp_path) + app.shutdown.assert_awaited_once() + assert not any(task.get_name() == 'local-agent-e2e-app' for task in asyncio.all_tasks()) + finally: + tasks = [task for task in asyncio.all_tasks() if task.get_name() == 'local-agent-e2e-app'] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/uv.lock b/uv.lock index f60f34455..623f676c9 100644 --- a/uv.lock +++ b/uv.lock @@ -2119,7 +2119,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2186,7 +2186,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.5.5" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612#eac2c60509534512f9a373cd0c801e75985e0612" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" },