import asyncio import json import logging from types import SimpleNamespace from typing import Any import pytest from bumblehive import BumblehiveConfig, SkillsManager from bumblehive.config import ProviderConfig from bumblehive.observability.events import make_event from bumblehive.observability.emitter import EventEmitter from bumblehive.protocols import ToolCall, ToolResult from bumblehive.protocols.errors import AgentError from bumblehive.tools import ToolManager from bumblehive.tools.scope import ( bind_tool_session, reset_tool_session, ) from bumblehive_server.runtime_service import RuntimeBusyError, RuntimeService from bumblehive_server.subagents import SUBAGENT_INSTRUCTIONS, observe_subagents from bumblehive_server.tool_context import capture_tool_call, current_task_id @pytest.mark.asyncio async def test_repeated_desktop_tool_calls_use_the_current_call_id() -> None: tools = ToolManager() @tools.tool(name="pending", description="Return this call's pending result.") async def pending() -> dict: await asyncio.sleep(0) return {"status": "pending", "task_id": current_task_id()} emitter = EventEmitter.from_hooks(capture_tool_call) for call_id in ("first-call", "second-call"): result = await tools.execute_call(ToolCall(call_id, "pending", {}), emitter=emitter) assert result.error is None assert result.content == {"status": "pending", "task_id": call_id} with pytest.raises(RuntimeError, match="active tool call"): current_task_id() class FakeRuntime: def __init__( self, config: BumblehiveConfig, *, initialize_error: Exception | None = None, ) -> None: self.config = config self.tools = ToolManager() self.initialize_error = initialize_error self.initialize_calls = 0 self.initialized = False self.closed = False self.deleted_sessions: list[str] = [] async def initialize_tools(self) -> None: self.initialize_calls += 1 if self.initialize_error is not None: raise self.initialize_error self.initialized = True async def close(self) -> None: self.closed = True async def delete_session(self, session_id: str) -> bool: self.deleted_sessions.append(session_id) return True class FakeModels: def __init__(self, response: Any = None, error: Exception | None = None) -> None: self.response = response self.error = error self.calls = 0 async def list(self) -> Any: self.calls += 1 if self.error is not None: raise self.error return self.response class FakeOpenAIClient: def __init__(self, models: FakeModels) -> None: self.models = models self.closed = False async def close(self) -> None: self.closed = True class FakeOpenAIClientFactory: def __init__(self, models: FakeModels) -> None: self.models = models self.calls: list[dict[str, str]] = [] self.clients: list[FakeOpenAIClient] = [] def __call__(self, *, api_key: str, base_url: str) -> FakeOpenAIClient: self.calls.append({"api_key": api_key, "base_url": base_url}) client = FakeOpenAIClient(self.models) self.clients.append(client) return client class RecordingSessionReader: def __init__(self, session_id: str = "child-session") -> None: self.session_id = session_id self.created_workspaces: list[Any] = [] self.created_titles: list[str | None] = [] self.created_parent_session_ids: list[str | None] = [] async def create_child( self, workspace: Any, *, title: str, parent_session_id: str, ) -> str: self.created_workspaces.append(workspace) self.created_titles.append(title) self.created_parent_session_ids.append(parent_session_id) return self.session_id class SequentialSessionReader(RecordingSessionReader): async def create_child( self, workspace: Any, *, title: str, parent_session_id: str, ) -> str: self.created_workspaces.append(workspace) self.created_titles.append(title) self.created_parent_session_ids.append(parent_session_id) return f"child-session-{len(self.created_workspaces)}" class RecordingSubagentObserver: def __init__(self) -> None: self.created: list[dict[str, str]] = [] self.events: list[Any] = [] self.results: list[dict[str, Any]] = [] self.errors: list[dict[str, str]] = [] self.cancelled: list[str] = [] async def on_created( self, *, session_id: str, workspace: str, title: str, content: str, ) -> None: self.created.append( { "session_id": session_id, "workspace": workspace, "title": title, "content": content, } ) async def on_event(self, event: Any) -> None: self.events.append(event) async def on_result( self, *, session_id: str, result: Any, duration_s: float, ) -> None: self.results.append( { "session_id": session_id, "result": result, "duration_s": duration_s, } ) async def on_error(self, *, session_id: str, message: str) -> None: self.errors.append({"session_id": session_id, "message": message}) async def on_cancelled(self, *, session_id: str) -> None: self.cancelled.append(session_id) class SubagentFakeStream: def __init__( self, runtime: "SubagentFakeRuntime", session_id: str, ) -> None: self.runtime = runtime self.session_id = session_id def __aiter__(self) -> Any: return self._iterate() async def _iterate(self) -> Any: yield make_event( "model.stream.content_delta", run_id=f"run-{self.session_id}", session_id=self.session_id, delta=f"Streaming {self.session_id}", ) await self.runtime.run_release.wait() if self.runtime.run_exception is not None: raise self.runtime.run_exception async def result(self) -> Any: return self.runtime.run_result async def aclose(self) -> None: return None class SubagentFakeRuntime(FakeRuntime): def __init__(self, config: BumblehiveConfig) -> None: super().__init__(config) self.tools = ToolManager() self.run_started = asyncio.Event() self.run_release = asyncio.Event() self.expected_run_count = 1 self.run_calls: list[dict[str, Any]] = [] self.run_result = SimpleNamespace(final_content="Child answer", error=None) self.run_exception: Exception | None = None def stream( self, message: str, *, session_id: str, config: dict[str, Any], ) -> SubagentFakeStream: self.run_calls.append( { "message": message, "session_id": session_id, "config": config, } ) if len(self.run_calls) >= self.expected_run_count: self.run_started.set() return SubagentFakeStream(self, session_id) def _start_subagent_tool( runtime: SubagentFakeRuntime, *, workspace: Any, title: str, task: str, ) -> asyncio.Task[ToolResult]: session_token = bind_tool_session("parent-session") try: return asyncio.create_task(runtime.tools.execute_call( ToolCall("delegate", "sub_agent", {"title": title, "task": task}), workspace=workspace, emitter=EventEmitter.from_hooks(capture_tool_call), )) finally: reset_tool_session(session_token) async def _finish_subagents(service: RuntimeService) -> None: async with asyncio.timeout(2): await asyncio.gather(*(entry.task for entry in service.subagents._tasks.values())) @pytest.mark.asyncio async def test_runtime_service_starts_without_a_config_file( tmp_path, caplog, ) -> None: runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(tmp_path / "missing.json", runtime_factory=factory) with caplog.at_level(logging.INFO, logger="uvicorn.error.bumblehive"): await service.startup() deleted = await service.delete_session("session-1") assert service.ready is True assert runtimes[0].initialized is True assert runtimes[0].initialize_calls == 1 assert deleted is True assert service.config.provider.model is None assert service.public_config()["provider"] == { "type": "openai_chat_completions", "api_key_configured": False, } assert service.public_config()["generation"] == { "max_completion_tokens": 16_384, } assert service.public_config()["runtime"] == { "workspace": str(service.workspace), "context_window_tokens": 200_000, "max_tool_result_chars": 20_000, "max_iterations": 300, } assert service.public_config()["agent"] == {} assert service.public_config()["mcp_servers"] == [] assert "[config] loaded | source=defaults | duration=" in caplog.text assert ( "[session] delete completed | session_id=session-1 | deleted=true" in caplog.text ) await service.shutdown() assert runtimes[0].closed is True @pytest.mark.asyncio async def test_runtime_service_updates_config_without_exposing_api_key( tmp_path, caplog, ) -> None: config_path = tmp_path / "config.json" BumblehiveConfig( provider=ProviderConfig(model="old-model", api_key="secret") ).to_json_file(config_path) runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(config_path, runtime_factory=factory) with caplog.at_level(logging.INFO, logger="uvicorn.error.bumblehive"): await service.startup() public = service.public_config() assert "api_key" not in public["provider"] assert public["provider"]["api_key_configured"] is True await service.update_config({"provider": {"model": "new-model"}}) saved = json.loads(config_path.read_text(encoding="utf-8")) assert saved == { "provider": { "type": "openai_chat_completions", "model": "new-model", "api_key": "secret", "base_url": None, }, "generation": { "max_completion_tokens": 16_384, "temperature": None, "reasoning_effort": None, "extra_body": None, }, "agent": { "instructions": None, "dynamic_context": {}, "skill_names": None, "tool_names": None, }, "runtime": { "workspace": None, "timezone": None, "context_window_tokens": 200_000, "max_tool_result_chars": 20_000, "max_iterations": 300, }, "mcp_servers": [], "skills_dir": None, } assert runtimes[0].closed is True assert runtimes[1].initialized is True assert runtimes[1].initialize_calls == 1 with caplog.at_level(logging.INFO, logger="uvicorn.error.bumblehive"): async with service.lease(): with pytest.raises(RuntimeBusyError): await service.update_config({"provider": {"model": "busy"}}) assert "[config] loaded | source=file | duration=" in caplog.text assert "[config] update completed | duration=" in caplog.text assert "[config] update rejected | reason=active_run" in caplog.text assert "secret" not in caplog.text await service.shutdown() assert runtimes[-1].closed is True @pytest.mark.asyncio async def test_runtime_service_can_clear_dynamic_context(tmp_path) -> None: config_path = tmp_path / "config.json" BumblehiveConfig.from_mapping( { "provider": {"model": "test-model"}, "agent": { "instructions": "Keep this instruction.", "dynamic_context": {"project": "bumblehive"}, }, } ).to_json_file(config_path) service = RuntimeService(config_path, runtime_factory=FakeRuntime) await service.startup() await service.update_config({"agent": {"dynamic_context": {}}}) assert service.config.agent.instructions == "Keep this instruction." assert service.config.agent.dynamic_context == {} saved = json.loads(config_path.read_text(encoding="utf-8")) assert saved["agent"]["dynamic_context"] == {} await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_masks_and_preserves_mcp_header_secrets( tmp_path, ) -> None: config_path = tmp_path / "config.json" BumblehiveConfig.from_mapping( { "provider": {"model": "test-model"}, "mcp_servers": [ { "name": "private-server", "url": "https://mcp.example.test", "headers": { "Authorization": "Bearer secret-token", "X-Tenant": "secret-tenant", }, } ], } ).to_json_file(config_path) runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(config_path, runtime_factory=factory) await service.startup() public = service.public_config() assert public["mcp_servers"] == [ { "name": "private-server", "url": "https://mcp.example.test", "headers": { "Authorization": "", "X-Tenant": "", }, } ] await service.update_config( { "mcp_servers": [ { "name": "renamed-server", "url": "https://mcp.example.test/v2", "headers": { "Authorization": "", "X-Tenant": "", "X-New": "new-secret", }, } ] } ) saved = json.loads(config_path.read_text(encoding="utf-8")) saved_server = saved["mcp_servers"][0] assert saved_server["name"] == "renamed-server" assert saved_server["url"] == "https://mcp.example.test/v2" assert saved_server["headers"] == { "Authorization": "Bearer secret-token", "X-Tenant": "secret-tenant", "X-New": "new-secret", } assert service.public_config()["mcp_servers"][0]["headers"] == { "Authorization": "", "X-Tenant": "", "X-New": "", } await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_tests_mcp_in_an_isolated_manager_and_restores_secrets( tmp_path, ) -> None: config_path = tmp_path / "config.json" BumblehiveConfig.from_mapping( { "mcp_servers": [ { "name": "private-server", "url": "https://old.example.test/mcp", "headers": { "Authorization": "Bearer saved-secret", }, } ], } ).to_json_file(config_path) runtimes: list[FakeRuntime] = [] managers: list[Any] = [] def runtime_factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime class IsolatedManager: def __init__(self, *, mcp_servers: list[Any]) -> None: self.servers = mcp_servers self.closed = False managers.append(self) async def connect_mcp_server(self, server_name: str) -> list[str]: assert server_name == "renamed-server" return ["mcp_renamed-server_search"] async def close(self) -> None: self.closed = True service = RuntimeService( config_path, runtime_factory=runtime_factory, tool_manager_factory=IsolatedManager, # type: ignore[arg-type] ) await service.startup() registered = await service.test_mcp_server( { "name": "renamed-server", "url": "https://new.example.test/mcp", "headers": {"Authorization": ""}, }, original_name="private-server", ) assert registered == ["mcp_renamed-server_search"] assert len(managers) == 1 assert managers[0].servers[0].headers == { "Authorization": "Bearer saved-secret" } assert managers[0].servers[0].url == "https://new.example.test/mcp" assert managers[0].servers[0].tool_timeout is None assert managers[0].servers[0].enabled_tools == ["*"] assert managers[0].closed is True assert service.config.mcp_servers[0].name == "private-server" await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_reloads_one_or_all_mcp_servers(tmp_path) -> None: runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(tmp_path / "config.json", runtime_factory=factory) await service.startup() calls: list[str] = [] async def reload_one(server_name: str) -> list[str]: calls.append(server_name) return [f"mcp_{server_name}_search"] async def reload_all() -> list[str]: calls.append("*") return ["mcp_docs_search", "mcp_github_search"] runtimes[0].tools = SimpleNamespace( reload_mcp_server=reload_one, reload_mcp=reload_all, ) assert await service.reload_mcp_servers("docs") == ["mcp_docs_search"] assert await service.reload_mcp_servers() == [ "mcp_docs_search", "mcp_github_search", ] assert calls == ["docs", "*"] async with service.lease(): with pytest.raises(RuntimeBusyError): await service.reload_mcp_servers("docs") await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_exposes_settings_choices(tmp_path) -> None: runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(tmp_path / "config.json", runtime_factory=factory) await service.startup() runtime = runtimes[0] runtime.skills = SimpleNamespace( list_skills=lambda: SimpleNamespace( skills=[ SimpleNamespace( name="review", description="Review project changes", ) ], errors=[ SimpleNamespace( path=tmp_path / "broken" / "SKILL.md", message="invalid frontmatter", ) ], ) ) runtime.tools = SimpleNamespace( list_tools=lambda: [ SimpleNamespace( name="read_file", description="Read a file", source="local", parallel_safe=True, ) ], list_mcp_server_statuses=lambda: [ SimpleNamespace( name="github", connected=True, registered_tools=["mcp_github_search"], ) ], ) assert service.settings_options() == { "skills": [ { "name": "review", "description": "Review project changes", } ], "skill_errors": [ { "path": str(tmp_path / "broken" / "SKILL.md"), "message": "invalid frontmatter", } ], "tools": [ { "name": "read_file", "description": "Read a file", "source": "local", "parallel_safe": True, } ], "mcp_statuses": [ { "name": "github", "connected": True, "registered_tools": ["mcp_github_search"], } ], } await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_manages_skills_only_while_idle(tmp_path) -> None: runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService(tmp_path / "config.json", runtime_factory=factory) await service.startup() runtime = runtimes[0] runtime.skills = SkillsManager(tmp_path / "skills") source = tmp_path / "review" source.mkdir() (source / "SKILL.md").write_text( "---\nname: review\ndescription: Review changes.\n---\n", encoding="utf-8", ) installed = await service.install_skills([source]) refreshed = await service.reload_skills() assert [skill.name for skill in installed.skills] == ["review"] assert [skill.name for skill in refreshed.skills] == ["review"] async with service.lease(): with pytest.raises(RuntimeBusyError): await service.remove_skill("review") removed = await service.remove_skill("review") assert removed.skills == [] await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_startup_closes_failed_runtime(tmp_path) -> None: runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime( config, initialize_error=RuntimeError("MCP unavailable"), ) runtimes.append(runtime) return runtime service = RuntimeService( tmp_path / "missing.json", runtime_factory=factory, ) with pytest.raises(RuntimeError, match="MCP unavailable"): await service.startup() assert service.ready is False assert runtimes[0].initialize_calls == 1 assert runtimes[0].closed is True @pytest.mark.asyncio async def test_runtime_service_keeps_current_when_replacement_init_fails( tmp_path, ) -> None: config_path = tmp_path / "config.json" BumblehiveConfig(provider=ProviderConfig(model="old-model")).to_json_file( config_path ) runtimes: list[FakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = FakeRuntime( config, initialize_error=(RuntimeError("MCP unavailable") if runtimes else None), ) runtimes.append(runtime) return runtime service = RuntimeService(config_path, runtime_factory=factory) await service.startup() with pytest.raises(RuntimeError, match="MCP unavailable"): await service.update_config({"provider": {"model": "new-model"}}) assert service.config.provider.model == "old-model" assert ( json.loads(config_path.read_text(encoding="utf-8"))["provider"]["model"] == "old-model" ) assert runtimes[0].closed is False assert runtimes[1].initialize_calls == 1 assert runtimes[1].closed is True await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_lists_models_with_new_url_and_key_without_saving( tmp_path, ) -> None: config_path = tmp_path / "config.json" BumblehiveConfig( provider=ProviderConfig( model="saved-model", api_key="saved-secret", base_url="https://saved.example/v1", ) ).to_json_file(config_path) models = FakeModels( SimpleNamespace( data=[ SimpleNamespace(id=" model-a "), SimpleNamespace(id="model-b"), SimpleNamespace(id="model-a"), SimpleNamespace(id=" "), SimpleNamespace(id=None), ] ) ) client_factory = FakeOpenAIClientFactory(models) service = RuntimeService( config_path, runtime_factory=FakeRuntime, openai_client_factory=client_factory, # type: ignore[arg-type] ) await service.startup() models = await service.list_models( api_key=" temporary-secret ", base_url=" https://DRAFT.example/v1/ ", ) assert models == ["model-a", "model-b"] assert client_factory.calls == [ { "api_key": "temporary-secret", "base_url": "https://draft.example/v1", } ] assert client_factory.clients[0].closed is True assert service.config.provider.api_key == "saved-secret" saved = json.loads(config_path.read_text(encoding="utf-8")) assert saved["provider"]["api_key"] == "saved-secret" assert saved["provider"]["base_url"] == "https://saved.example/v1" await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_reuses_saved_key_for_the_same_url(tmp_path) -> None: config_path = tmp_path / "config.json" BumblehiveConfig( provider=ProviderConfig( api_key="saved-secret", base_url="https://saved.example/v1/", ) ).to_json_file(config_path) client_factory = FakeOpenAIClientFactory( FakeModels(SimpleNamespace(data=[SimpleNamespace(id="saved-model")])) ) service = RuntimeService( config_path, runtime_factory=FakeRuntime, openai_client_factory=client_factory, # type: ignore[arg-type] ) await service.startup() models = await service.list_models(base_url="https://SAVED.example/v1") assert models == ["saved-model"] assert client_factory.calls == [ { "api_key": "saved-secret", "base_url": "https://saved.example/v1", } ] assert client_factory.clients[0].closed is True await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_rejects_new_url_without_a_new_key(tmp_path) -> None: config_path = tmp_path / "config.json" BumblehiveConfig( provider=ProviderConfig( api_key="saved-secret", base_url="https://saved.example/v1", ) ).to_json_file(config_path) client_factory = FakeOpenAIClientFactory(FakeModels()) service = RuntimeService( config_path, runtime_factory=FakeRuntime, openai_client_factory=client_factory, # type: ignore[arg-type] ) await service.startup() with pytest.raises(ValueError, match="different Base URL"): await service.list_models(base_url="https://new.example/v1") assert client_factory.calls == [] await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_wraps_model_provider_failures(tmp_path) -> None: from bumblehive_server.runtime_service import ModelListError client_factory = FakeOpenAIClientFactory( FakeModels(error=RuntimeError("provider unavailable")) ) service = RuntimeService( tmp_path / "missing.json", runtime_factory=FakeRuntime, openai_client_factory=client_factory, # type: ignore[arg-type] ) await service.startup() with pytest.raises(ModelListError, match="third-party model query failed"): await service.list_models( base_url="https://new.example/v1", api_key="temporary-secret", ) assert client_factory.clients[0].closed is True await service.shutdown() @pytest.mark.asyncio async def test_runtime_service_logs_config_load_failure( tmp_path, caplog, ) -> None: config_path = tmp_path / "config.json" config_path.write_text("{", encoding="utf-8") service = RuntimeService(config_path) with caplog.at_level(logging.INFO, logger="uvicorn.error.bumblehive"): with pytest.raises(json.JSONDecodeError): await service.startup() failure_records = [ record for record in caplog.records if record.getMessage().startswith("[config] load failed") ] assert len(failure_records) == 1 assert failure_records[0].exc_info is not None @pytest.mark.asyncio async def test_runtime_service_registers_subagent_on_startup_and_replacement( tmp_path, ) -> None: reader = RecordingSessionReader() runtimes: list[SubagentFakeRuntime] = [] def factory(config: BumblehiveConfig) -> Any: runtime = SubagentFakeRuntime(config) runtimes.append(runtime) return runtime service = RuntimeService( tmp_path / "config.json", session_reader=reader, # type: ignore[arg-type] runtime_factory=factory, ) await service.startup() await service.update_config({"provider": {"model": "replacement"}}) assert len(runtimes) == 2 assert all(runtime.tools.get_tool("sub_agent") is not None for runtime in runtimes) tool = runtimes[0].tools.get_tool("sub_agent") assert tool is not None assert tool.parameters["required"] == ["title", "task"] assert tool.parameters["properties"]["title"]["maxLength"] == 80 assert set(tool.parameters["properties"]) == {"title", "task"} assert "background=false" not in tool.description assert "independent read-only sub-agent" in tool.description await service.shutdown() @pytest.mark.asyncio async def test_subagent_tool_runs_a_read_only_child_session(tmp_path) -> None: reader = RecordingSessionReader() observer = RecordingSubagentObserver() runtime: SubagentFakeRuntime | None = None def factory(config: BumblehiveConfig) -> Any: nonlocal runtime runtime = SubagentFakeRuntime(config) return runtime service = RuntimeService( tmp_path / "config.json", session_reader=reader, # type: ignore[arg-type] runtime_factory=factory, ) await service.startup() assert runtime is not None workspace = tmp_path / "workspace" with observe_subagents(observer): tool_task = _start_subagent_tool( runtime, workspace=workspace, title="Inspect the project", task=" Inspect the project ", ) await runtime.run_started.wait() assert reader.created_workspaces == [workspace.resolve()] assert reader.created_titles == ["Inspect the project"] assert reader.created_parent_session_ids == ["parent-session"] assert observer.created == [ { "session_id": "child-session", "workspace": str(workspace.resolve()), "title": "Inspect the project", "content": "Inspect the project", } ] assert [event.session_id for event in observer.events] == ["child-session"] assert runtime.run_calls == [ { "message": [{ "role": "user", "content": SUBAGENT_INSTRUCTIONS + "Inspect the project", "_bumblehive_ui": {"content": "Inspect the project"}, }], "session_id": "child-session", "config": { "runtime": {"workspace": str(workspace.resolve())}, "agent": { "tool_names": ( "read_file", "list_dir", "find_files", "grep", ) }, }, } ] result = await asyncio.wait_for(tool_task, 1) assert result.error is None assert result.content == {"status": "pending", "task_id": "delegate"} assert not runtime.run_release.is_set() runtime.run_release.set() await _finish_subagents(service) assert [item["session_id"] for item in observer.results] == [ "child-session" ] await service.shutdown() @pytest.mark.asyncio async def test_subagent_tool_runs_multiple_calls_in_parallel(tmp_path) -> None: reader = SequentialSessionReader() observer = RecordingSubagentObserver() runtime: SubagentFakeRuntime | None = None def factory(config: BumblehiveConfig) -> Any: nonlocal runtime runtime = SubagentFakeRuntime(config) return runtime service = RuntimeService( tmp_path / "config.json", session_reader=reader, # type: ignore[arg-type] runtime_factory=factory, ) await service.startup() assert runtime is not None runtime.expected_run_count = 2 session_token = bind_tool_session("parent-session") try: with observe_subagents(observer): calls_task = asyncio.create_task( runtime.tools.execute_many( [ ToolCall( id="call-1", name="sub_agent", arguments={ "title": "Inspect A", "task": "Inspect task A", }, ), ToolCall( id="call-2", name="sub_agent", arguments={ "title": "Inspect B", "task": "Inspect task B", }, ), ], tool_names=["sub_agent"], workspace=tmp_path / "workspace", emitter=EventEmitter.from_hooks(capture_tool_call), ) ) finally: reset_tool_session(session_token) await asyncio.wait_for(runtime.run_started.wait(), timeout=1) assert [call["message"][0]["content"] for call in runtime.run_calls] == [ SUBAGENT_INSTRUCTIONS + "Inspect task A", SUBAGENT_INSTRUCTIONS + "Inspect task B", ] assert reader.created_titles == ["Inspect A", "Inspect B"] assert { call["session_id"] for call in runtime.run_calls } == {"child-session-1", "child-session-2"} assert { event.session_id for event in observer.events } == {"child-session-1", "child-session-2"} results = await asyncio.wait_for(calls_task, 1) assert [result.content for result in results] == [ {"status": "pending", "task_id": "call-1"}, {"status": "pending", "task_id": "call-2"}, ] runtime.run_release.set() await _finish_subagents(service) assert { item["session_id"] for item in observer.results } == {"child-session-1", "child-session-2"} await service.shutdown() @pytest.mark.asyncio async def test_subagent_tool_cleans_running_state_on_error_and_cancellation( tmp_path, ) -> None: reader = RecordingSessionReader() observer = RecordingSubagentObserver() runtime: SubagentFakeRuntime | None = None def factory(config: BumblehiveConfig) -> Any: nonlocal runtime runtime = SubagentFakeRuntime(config) return runtime service = RuntimeService( tmp_path / "config.json", session_reader=reader, # type: ignore[arg-type] runtime_factory=factory, ) await service.startup() assert runtime is not None runtime.run_result = SimpleNamespace( final_content=None, error=AgentError(code="model_error", message="child failed"), ) with observe_subagents(observer): failed_task = _start_subagent_tool( runtime, workspace=tmp_path / "workspace", title="Test failure", task="Fail", ) await runtime.run_started.wait() runtime.run_release.set() assert (await failed_task).content == {"status": "pending", "task_id": "delegate"} await _finish_subagents(service) assert not service.subagents.is_running("child-session") assert [item["session_id"] for item in observer.results] == [ "child-session" ] runtime.run_started.clear() runtime.run_release.clear() runtime.run_result = SimpleNamespace(final_content="unused", error=None) runtime.run_exception = RuntimeError("stream failed") with observe_subagents(observer): errored_task = _start_subagent_tool( runtime, workspace=tmp_path / "workspace", title="Test stream error", task="Error", ) await runtime.run_started.wait() runtime.run_release.set() assert (await errored_task).error is None await _finish_subagents(service) assert not service.subagents.is_running("child-session") assert observer.errors == [ { "session_id": "child-session", "message": "stream failed", } ] runtime.run_started.clear() runtime.run_release.clear() runtime.run_exception = None runtime.run_result = SimpleNamespace(final_content="unused", error=None) with observe_subagents(observer): cancelled_task = _start_subagent_tool( runtime, workspace=tmp_path / "workspace", title="Test cancellation", task="Wait", ) await runtime.run_started.wait() assert (await cancelled_task).error is None await service.subagents.cancel("child-session") assert not service.subagents.is_running("child-session") assert observer.cancelled == ["child-session"] await service.shutdown()