server/tests/test_runtime_service.py
Rama main · réplica de lectura del repositorio original.
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()