Files
nanobot/tests/test_nanobot_facade.py
T

1402 lines
47 KiB
Python
Raw Normal View History

"""Tests for the Nanobot programmatic facade."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
2026-06-21 16:46:04 +08:00
from types import SimpleNamespace
2026-06-21 15:45:17 +08:00
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
2026-06-21 16:46:04 +08:00
from nanobot.nanobot import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
Nanobot,
RunResult,
RunStream,
SessionInfo,
SessionSnapshot,
StreamEvent,
StreamEventType,
)
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
data = {
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
"agents": {"defaults": {"model": "openai/gpt-4.1"}},
}
if overrides:
data.update(overrides)
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps(data))
return config_path
2026-06-21 16:46:04 +08:00
def _fake_provider(name: str, *, max_tokens: int = 8192) -> MagicMock:
provider = MagicMock(name=name)
provider.get_default_model.return_value = name
provider.generation = SimpleNamespace(
max_tokens=max_tokens,
temperature=0.1,
reasoning_effort=None,
)
return provider
def test_from_config_missing_file():
with pytest.raises(FileNotFoundError):
Nanobot.from_config("/nonexistent/config.json")
def test_from_config_creates_instance(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
assert bot._loop is not None
assert bot._loop.workspace == tmp_path
2026-06-21 16:46:04 +08:00
def test_from_config_accepts_default_model_override(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(
config_path,
workspace=tmp_path,
model="openai/gpt-4.1-mini",
)
assert bot.runtime.model == "openai/gpt-4.1-mini"
assert bot._loop.model_preset is None
def test_from_config_accepts_default_model_preset(tmp_path):
config_path = _write_config(
tmp_path,
{
"modelPresets": {
"fast": {
"model": "openai/gpt-4.1-mini",
"provider": "openrouter",
}
}
},
)
bot = Nanobot.from_config(config_path, workspace=tmp_path, model_preset="fast")
assert bot.runtime.model == "openai/gpt-4.1-mini"
assert bot._loop.model_preset == "fast"
def test_from_config_rejects_multiple_model_selectors(tmp_path):
config_path = _write_config(tmp_path)
with pytest.raises(ValueError, match="mutually exclusive"):
Nanobot.from_config(
config_path,
workspace=tmp_path,
model="openai/gpt-4.1",
model_preset="fast",
)
def test_from_config_default_path():
from nanobot.config.schema import Config
with patch("nanobot.config.loader.load_config") as mock_load, \
patch("nanobot.providers.factory.make_provider") as mock_prov:
mock_load.return_value = Config()
mock_prov.return_value = MagicMock()
mock_prov.return_value.get_default_model.return_value = "test"
mock_prov.return_value.generation.max_tokens = 4096
Nanobot.from_config()
mock_load.assert_called_once_with(None)
@pytest.mark.asyncio
async def test_run_returns_result(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
from nanobot.bus.events import OutboundMessage
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="Hello back!"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
result = await bot.run("hi")
assert isinstance(result, RunResult)
assert result.content == "Hello back!"
2026-06-21 15:45:17 +08:00
bot._loop.process_direct.assert_awaited_once_with(
2026-06-21 16:46:04 +08:00
"hi",
session_key="sdk:default",
hooks=ANY,
2026-06-21 15:45:17 +08:00
)
@pytest.mark.asyncio
async def test_run_with_hooks(tmp_path):
2026-06-21 15:45:17 +08:00
from nanobot.agent.hook import AgentHook, AgentHookContext, SDKCaptureHook
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
class TestHook(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
pass
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="done"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
result = await bot.run("hi", hooks=[TestHook()])
assert result.content == "done"
assert bot._loop._extra_hooks == []
2026-06-21 16:46:04 +08:00
hooks = bot._loop.process_direct.await_args.kwargs["hooks"]
assert len(hooks) == 2
assert isinstance(hooks[0], SDKCaptureHook)
assert isinstance(hooks[1], TestHook)
@pytest.mark.asyncio
async def test_run_hooks_restored_on_error(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
from nanobot.agent.hook import AgentHook
bot._loop.process_direct = AsyncMock(side_effect=RuntimeError("boom"))
original_hooks = bot._loop._extra_hooks
with pytest.raises(RuntimeError):
await bot.run("hi", hooks=[AgentHook()])
assert bot._loop._extra_hooks is original_hooks
@pytest.mark.asyncio
async def test_run_none_response(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(return_value=None)
result = await bot.run("hi")
assert result.content == ""
def test_workspace_override(tmp_path):
config_path = _write_config(tmp_path)
custom_ws = tmp_path / "custom_workspace"
custom_ws.mkdir()
bot = Nanobot.from_config(config_path, workspace=custom_ws)
assert bot._loop.workspace == custom_ws
def test_sdk_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
}
}
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
@pytest.mark.asyncio
async def test_run_custom_session_key(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="ok"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
await bot.run("hi", session_key="user-alice")
2026-06-21 15:45:17 +08:00
bot._loop.process_direct.assert_awaited_once_with(
2026-06-21 16:46:04 +08:00
"hi",
session_key="user-alice",
hooks=ANY,
2026-06-21 15:45:17 +08:00
)
def test_import_from_top_level():
import nanobot
assert nanobot.Nanobot is Nanobot
assert nanobot.RunResult is RunResult
2026-06-21 16:46:04 +08:00
assert nanobot.RunStream is RunStream
assert nanobot.SessionInfo is SessionInfo
assert nanobot.SessionSnapshot is SessionSnapshot
assert nanobot.StreamEvent is StreamEvent
assert nanobot.StreamEventType is StreamEventType
assert nanobot.STREAM_EVENT_TEXT_DELTA == STREAM_EVENT_TEXT_DELTA
assert nanobot.STREAM_EVENT_RUN_COMPLETED == STREAM_EVENT_RUN_COMPLETED
assert nanobot.STREAM_EVENT_TYPES == STREAM_EVENT_TYPES
def test_stream_event_constants_are_stable():
assert STREAM_EVENT_TYPES == (
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
)
assert STREAM_EVENT_TYPES == (
"run.started",
"text.delta",
"text.completed",
"reasoning.delta",
"reasoning.completed",
"tool.started",
"tool.completed",
"tool.failed",
"run.completed",
"run.failed",
)
assert len(set(STREAM_EVENT_TYPES)) == len(STREAM_EVENT_TYPES)
# ---------------------------------------------------------------------------
# RunResult.tools_used / messages — populated from the agent iterations
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_populates_tools_used_across_iterations(tmp_path):
"""tools_used collects every tool name fired across all iterations, in order."""
from nanobot.agent.hook import AgentHookContext
from nanobot.bus.events import OutboundMessage
from nanobot.providers.base import ToolCallRequest
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks):
messages = [{"role": "user", "content": message}]
ctx1 = AgentHookContext(iteration=0, messages=messages)
ctx1.tool_calls = [
ToolCallRequest(id="c1", name="read_file", arguments={}),
2026-05-15 15:54:36 +08:00
ToolCallRequest(id="c2", name="grep", arguments={}),
]
2026-06-21 16:46:04 +08:00
for h in hooks:
await h.after_iteration(ctx1)
messages.append({"role": "assistant", "content": "ok"})
ctx2 = AgentHookContext(iteration=1, messages=messages)
ctx2.tool_calls = [ToolCallRequest(id="c3", name="web_fetch", arguments={})]
2026-06-21 16:46:04 +08:00
for h in hooks:
await h.after_iteration(ctx2)
return OutboundMessage(channel="cli", chat_id="direct", content="final")
bot._loop.process_direct = fake_process_direct
result = await bot.run("do stuff")
assert result.content == "final"
2026-05-15 15:54:36 +08:00
assert result.tools_used == ["read_file", "grep", "web_fetch"]
@pytest.mark.asyncio
async def test_run_populates_final_messages(tmp_path):
"""messages reflects the agent's message list at the last iteration."""
from nanobot.agent.hook import AgentHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks):
messages = [
{"role": "user", "content": message},
{"role": "assistant", "content": "hi there"},
]
ctx = AgentHookContext(iteration=0, messages=messages)
2026-06-21 16:46:04 +08:00
for h in hooks:
await h.after_iteration(ctx)
return OutboundMessage(channel="cli", chat_id="direct", content="hi there")
bot._loop.process_direct = fake_process_direct
result = await bot.run("hello")
assert result.messages == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
@pytest.mark.asyncio
async def test_run_no_iterations_leaves_defaults_empty(tmp_path):
"""If process_direct never triggers after_iteration, tools_used/messages stay []."""
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="cli", chat_id="direct", content="noop"),
)
result = await bot.run("hi")
assert result.tools_used == []
assert result.messages == []
2026-06-21 16:46:04 +08:00
assert result.usage == {}
assert result.stop_reason is None
assert result.error is None
@pytest.mark.asyncio
async def test_run_populates_observability_fields(tmp_path):
from nanobot.agent.hook import AgentRunHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(message, *, session_key, hooks):
ctx = AgentRunHookContext(
messages=[
{"role": "user", "content": message},
{"role": "assistant", "content": "done"},
],
final_content="done",
tools_used=["read_file"],
usage={"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
stop_reason="completed",
error=None,
tool_events=[{"tool": "read_file", "status": "ok"}],
)
for h in hooks:
await h.after_run(ctx)
return OutboundMessage(
channel="cli",
chat_id="direct",
content="done",
metadata={"latency_ms": 42},
)
bot._loop.process_direct = fake_process_direct
result = await bot.run("work")
assert result.content == "done"
assert result.tools_used == ["read_file"]
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}
assert result.stop_reason == "completed"
assert result.error is None
assert result.metadata == {"latency_ms": 42}
@pytest.mark.asyncio
async def test_run_ephemeral_still_captures_runner_observability(tmp_path):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="done",
tool_calls=[],
usage={"total_tokens": 3},
))
bot = Nanobot(AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
))
result = await bot.run("hi", ephemeral=True)
assert result.content == "done"
assert result.usage["total_tokens"] == 3
assert result.usage["provider_tokens"] == 3
@pytest.mark.asyncio
async def test_run_forwards_non_default_runtime_options(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="sdk", chat_id="chat-a", content="ok"),
)
await bot.run(
"hi",
session_key="sdk:chat-a",
channel="sdk",
chat_id="chat-a",
sender_id="alice",
media=["/tmp/image.png"],
ephemeral=True,
)
bot._loop.process_direct.assert_awaited_once_with(
"hi",
session_key="sdk:chat-a",
channel="sdk",
chat_id="chat-a",
sender_id="alice",
media=["/tmp/image.png"],
ephemeral=True,
_run_extra_hooks_for_ephemeral=True,
hooks=ANY,
)
@pytest.mark.asyncio
async def test_run_allows_parallel_sessions_without_model_override(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
entered: list[str] = []
both_entered = asyncio.Event()
async def fake_process_direct(message, *, session_key, hooks):
entered.append(session_key)
if len(entered) == 2:
both_entered.set()
await asyncio.wait_for(both_entered.wait(), timeout=1)
return OutboundMessage(channel="cli", chat_id="direct", content=message)
bot._loop.process_direct = fake_process_direct
left, right = await asyncio.gather(
bot.run("left", session_key="sdk:left"),
bot.run("right", session_key="sdk:right"),
)
assert left.content == "left"
assert right.content == "right"
assert set(entered) == {"sdk:left", "sdk:right"}
@pytest.mark.asyncio
async def test_run_model_overrides_can_overlap_without_default_mutation(tmp_path):
2026-06-21 16:46:04 +08:00
from nanobot.bus.events import OutboundMessage
from nanobot.providers.factory import ProviderSnapshot
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
assert not hasattr(bot, "_runtime_overrides")
original_runtime = bot._loop.runtime_resolver.runtime
active_runs: list[tuple[str, str]] = []
2026-06-21 16:46:04 +08:00
first_entered = asyncio.Event()
both_entered = asyncio.Event()
2026-06-21 16:46:04 +08:00
release_first = asyncio.Event()
def fake_resolve(*, model, model_preset, config):
2026-06-21 16:46:04 +08:00
assert model is not None
assert model_preset is None
assert config is bot._config
return runtime_from_provider_snapshot(ProviderSnapshot(
2026-06-21 16:46:04 +08:00
provider=_fake_provider(model, max_tokens=2048),
model=model,
context_window_tokens=4096,
signature=("sdk", model),
))
2026-06-21 16:46:04 +08:00
bot._loop.runtime_resolver.resolve_override = MagicMock(side_effect=fake_resolve)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks, runtime):
active_runs.append((session_key, runtime.model))
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
if message == "first":
first_entered.set()
if len(active_runs) == 2:
both_entered.set()
await asyncio.wait_for(release_first.wait(), timeout=1)
2026-06-21 16:46:04 +08:00
return OutboundMessage(channel="cli", chat_id="direct", content=message)
bot._loop.process_direct = fake_process_direct
first = asyncio.create_task(bot.run(
"first",
session_key="sdk:first",
model="model:first",
))
2026-06-21 16:46:04 +08:00
await asyncio.wait_for(first_entered.wait(), timeout=1)
second = asyncio.create_task(bot.run(
"second",
session_key="sdk:second",
model="model:second",
))
await asyncio.wait_for(both_entered.wait(), timeout=1)
assert not first.done()
2026-06-21 16:46:04 +08:00
assert not second.done()
release_first.set()
first_result, second_result = await asyncio.gather(first, second)
assert first_result.content == "first"
assert second_result.content == "second"
assert set(active_runs) == {
("sdk:first", "model:first"),
("sdk:second", "model:second"),
}
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
@pytest.mark.asyncio
async def test_run_model_override_is_per_run_without_default_mutation(tmp_path):
2026-06-21 16:46:04 +08:00
from nanobot.bus.events import OutboundMessage
from nanobot.providers.factory import ProviderSnapshot
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
original_runtime = bot._loop.runtime_resolver.runtime
2026-06-21 16:46:04 +08:00
override_provider = _fake_provider("override-provider", max_tokens=2048)
override = ProviderSnapshot(
provider=override_provider,
model="openai/gpt-4.1-mini",
context_window_tokens=4096,
signature=("sdk", "override"),
)
override_runtime = runtime_from_provider_snapshot(override)
bot._loop.runtime_resolver.resolve_override = MagicMock(
return_value=override_runtime
)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks, runtime):
assert runtime is override_runtime
assert not hasattr(bot._loop.runner, "provider")
assert runtime.model == "openai/gpt-4.1-mini"
assert runtime.context_window_tokens == 4096
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
bot._loop.process_direct = fake_process_direct
result = await bot.run("hi", model="openai/gpt-4.1-mini")
assert result.content == "ok"
bot._loop.runtime_resolver.resolve_override.assert_called_once_with(
2026-06-21 16:46:04 +08:00
model="openai/gpt-4.1-mini",
model_preset=None,
config=bot._config,
2026-06-21 16:46:04 +08:00
)
assert not hasattr(bot._loop.runner, "provider")
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
@pytest.mark.asyncio
async def test_run_model_preset_override_is_per_run(tmp_path):
from nanobot.bus.events import OutboundMessage
from nanobot.providers.factory import ProviderSnapshot
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
original_runtime = bot._loop.runtime_resolver.runtime
2026-06-21 16:46:04 +08:00
override_provider = _fake_provider("preset-provider", max_tokens=1024)
override = ProviderSnapshot(
provider=override_provider,
model="openai/gpt-4.1-mini",
context_window_tokens=2048,
signature=("preset", "fast"),
)
override_runtime = runtime_from_provider_snapshot(override, model_preset="fast")
bot._loop.runtime_resolver.resolve_override = MagicMock(
return_value=override_runtime
)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks, runtime):
assert runtime is override_runtime
2026-06-21 16:46:04 +08:00
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
bot._loop.process_direct = fake_process_direct
await bot.run("hi", model_preset="fast")
bot._loop.runtime_resolver.resolve_override.assert_called_once_with(
model=None,
model_preset="fast",
config=bot._config,
)
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
assert bot._loop.model_preset is None
@pytest.mark.asyncio
async def test_run_rejects_multiple_model_selectors(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
with pytest.raises(ValueError, match="mutually exclusive"):
await bot.run("hi", model="openai/gpt-4.1", model_preset="fast")
@pytest.mark.asyncio
async def test_run_user_hooks_still_fire_alongside_capture(tmp_path):
"""Capture hook must not displace user-provided hooks."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
seen_iterations: list[int] = []
class UserHook(AgentHook):
async def after_iteration(self, context: AgentHookContext) -> None:
seen_iterations.append(context.iteration)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks):
assert len(hooks) == 2, f"expected capture + user hook, got {len(hooks)}"
ctx = AgentHookContext(iteration=7, messages=[])
2026-06-21 16:46:04 +08:00
for h in hooks:
await h.after_iteration(ctx)
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
bot._loop.process_direct = fake_process_direct
await bot.run("x", hooks=[UserHook()])
assert seen_iterations == [7]
@pytest.mark.asyncio
async def test_concurrent_run_hooks_are_isolated_per_call(tmp_path):
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.bus.events import OutboundMessage
from nanobot.providers.base import ToolCallRequest
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
seen_by_hook: dict[str, list[str]] = {"alpha": [], "beta": []}
class UserHook(AgentHook):
def __init__(self, name: str) -> None:
self.name = name
async def after_iteration(self, context: AgentHookContext) -> None:
seen_by_hook[self.name].append(context.messages[0]["content"])
started = 0
both_started = asyncio.Event()
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks=None):
nonlocal started
started += 1
if started == 2:
both_started.set()
await both_started.wait()
2026-06-21 16:46:04 +08:00
active_hooks = hooks or []
messages = [{"role": "user", "content": message}]
ctx = AgentHookContext(iteration=0, messages=messages)
ctx.tool_calls = [
ToolCallRequest(id=f"call-{message}", name=f"tool_{message}", arguments={})
]
2026-06-21 16:46:04 +08:00
for h in active_hooks:
await h.after_iteration(ctx)
return OutboundMessage(channel="cli", chat_id="direct", content=f"done {message}")
bot._loop.process_direct = fake_process_direct
alpha, beta = await asyncio.gather(
bot.run("alpha", hooks=[UserHook("alpha")]),
bot.run("beta", hooks=[UserHook("beta")]),
)
assert alpha.content == "done alpha"
assert beta.content == "done beta"
assert alpha.tools_used == ["tool_alpha"]
assert beta.tools_used == ["tool_beta"]
assert seen_by_hook == {"alpha": ["alpha"], "beta": ["beta"]}
@pytest.mark.asyncio
async def test_run_restores_extra_hooks_even_on_populated_iterations(tmp_path):
"""Previously-installed _extra_hooks must be restored regardless of capture state."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
sentinel_hook = AgentHook()
bot._loop._extra_hooks = [sentinel_hook]
2026-06-21 16:46:04 +08:00
async def fake_process_direct(message, *, session_key, hooks):
ctx = AgentHookContext(iteration=0, messages=[])
2026-06-21 16:46:04 +08:00
for h in [*bot._loop._extra_hooks, *hooks]:
await h.after_iteration(ctx)
return OutboundMessage(channel="cli", chat_id="direct", content="done")
bot._loop.process_direct = fake_process_direct
await bot.run("hello")
assert bot._loop._extra_hooks == [sentinel_hook]
2026-06-03 21:11:20 +08:00
2026-06-21 16:46:04 +08:00
@pytest.mark.asyncio
async def test_stream_yields_text_events_in_order(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
assert message == "hi"
assert session_key == "sdk:default"
await on_stream("Hel")
await on_stream("lo")
await on_stream_end(resuming=False)
return OutboundMessage(channel="cli", chat_id="direct", content="Hello")
bot._loop.process_direct = fake_process_direct
events = [event async for event in bot.stream("hi")]
assert all(event.type in STREAM_EVENT_TYPES for event in events)
assert [event.type for event in events] == [
"run.started",
"text.delta",
"text.delta",
"text.completed",
"run.completed",
]
assert events[1].delta == "Hel"
assert events[2].delta == "lo"
assert events[3].content == "Hello"
assert events[4].result is not None
assert events[4].result.content == "Hello"
@pytest.mark.asyncio
async def test_run_streamed_wait_returns_full_result_without_consuming_events(tmp_path):
from nanobot.agent.hook import AgentRunHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
await on_stream("done")
await on_stream_end(resuming=False)
ctx = AgentRunHookContext(
messages=[
{"role": "user", "content": message},
{"role": "assistant", "content": "done"},
],
final_content="done",
tools_used=["read_file"],
usage={"total_tokens": 9},
stop_reason="completed",
)
for hook in hooks:
await hook.after_run(ctx)
return OutboundMessage(
channel="cli",
chat_id="direct",
content="done",
metadata={"latency_ms": 5},
)
bot._loop.process_direct = fake_process_direct
run = await bot.run_streamed("work")
assert isinstance(run, RunStream)
result = await run.wait()
assert result.content == "done"
assert result.tools_used == ["read_file"]
assert result.usage == {"total_tokens": 9}
assert result.stop_reason == "completed"
assert result.metadata == {"latency_ms": 5}
@pytest.mark.asyncio
async def test_run_streamed_cancel_releases_full_queue_without_consuming(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
for i in range(400):
await on_stream(str(i))
await on_stream_end(resuming=False)
return OutboundMessage(channel="cli", chat_id="direct", content="done")
bot._loop.process_direct = fake_process_direct
run = await bot.run_streamed("many")
await asyncio.sleep(0.05)
assert not run.done
await asyncio.wait_for(run.cancel(), timeout=1)
assert run.done
@pytest.mark.asyncio
async def test_run_streamed_text_returns_final_content(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="cli", chat_id="direct", content="plain text"),
)
run = await bot.run_streamed("hi")
assert await run.text() == "plain text"
@pytest.mark.asyncio
async def test_run_streamed_forwards_runtime_options(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="sdk", chat_id="chat-a", content="ok"),
)
run = await bot.run_streamed(
"hi",
session_key="sdk:chat-a",
channel="sdk",
chat_id="chat-a",
sender_id="alice",
media=["/tmp/image.png"],
ephemeral=True,
)
await run.wait()
bot._loop.process_direct.assert_awaited_once()
args, kwargs = bot._loop.process_direct.call_args
assert args == ("hi",)
assert kwargs["session_key"] == "sdk:chat-a"
assert kwargs["channel"] == "sdk"
assert kwargs["chat_id"] == "chat-a"
assert kwargs["sender_id"] == "alice"
assert kwargs["media"] == ["/tmp/image.png"]
assert kwargs["ephemeral"] is True
assert callable(kwargs["on_stream"])
assert callable(kwargs["on_stream_end"])
assert kwargs["hooks"]
assert kwargs["runtime"] is bot._loop.runtime_resolver.runtime
2026-06-21 16:46:04 +08:00
@pytest.mark.asyncio
async def test_run_streamed_model_override_reports_admitted_runtime(tmp_path):
2026-06-21 16:46:04 +08:00
from nanobot.bus.events import OutboundMessage
from nanobot.providers.factory import ProviderSnapshot
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
original_runtime = bot._loop.runtime_resolver.runtime
2026-06-21 16:46:04 +08:00
override_provider = _fake_provider("stream-provider", max_tokens=2048)
override = ProviderSnapshot(
provider=override_provider,
model="openai/gpt-4.1-mini",
context_window_tokens=4096,
signature=("sdk", "stream"),
)
override_runtime = runtime_from_provider_snapshot(override)
bot._loop.runtime_resolver.resolve_override = MagicMock(
return_value=override_runtime
)
2026-06-21 16:46:04 +08:00
async def fake_process_direct(
message,
*,
session_key,
on_stream,
on_stream_end,
hooks,
runtime,
):
assert runtime is override_runtime
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
await on_stream("ok")
await on_stream_end(resuming=False)
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
bot._loop.process_direct = fake_process_direct
run = await bot.run_streamed("hi", model="openai/gpt-4.1-mini")
events = [event async for event in run.stream_events()]
result = await run.wait()
assert result.content == "ok"
assert events[0].type == "run.started"
assert events[0].metadata["model"] == "openai/gpt-4.1-mini"
assert events[0].metadata["model_preset"] is None
assert bot._loop.runtime_resolver.runtime is original_runtime
2026-06-21 16:46:04 +08:00
@pytest.mark.asyncio
async def test_stream_rejects_multiple_model_selectors(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
with pytest.raises(ValueError, match="mutually exclusive"):
_ = [event async for event in bot.stream(
"hi",
model="openai/gpt-4.1",
model_preset="fast",
)]
@pytest.mark.asyncio
async def test_run_streamed_emits_tool_events(tmp_path):
from nanobot.agent.hook import AgentHookContext
from nanobot.bus.events import OutboundMessage
from nanobot.providers.base import ToolCallRequest
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
calls = [
ToolCallRequest(id="call_ok", name="read_file", arguments={"path": "README.md"}),
ToolCallRequest(id="call_bad", name="exec", arguments={"cmd": "false"}),
]
ctx = AgentHookContext(iteration=2, messages=[{"role": "user", "content": message}])
ctx.tool_calls = calls
for hook in hooks:
await hook.before_execute_tools(ctx)
ctx.tool_events = [
{"name": "read_file", "status": "ok", "detail": "README.md"},
{"name": "exec", "status": "error", "detail": "exit 1"},
]
for hook in hooks:
await hook.after_iteration(ctx)
return OutboundMessage(channel="cli", chat_id="direct", content="done")
bot._loop.process_direct = fake_process_direct
run = await bot.run_streamed("inspect")
events = [event async for event in run.stream_events()]
await run.wait()
assert [event.type for event in events] == [
"run.started",
"tool.started",
"tool.started",
"tool.completed",
"tool.failed",
"run.completed",
]
assert events[1].name == "read_file"
assert events[1].tool_call_id == "call_ok"
assert events[1].arguments == {"path": "README.md"}
assert events[3].metadata["status"] == "ok"
assert events[4].name == "exec"
assert events[4].error == "exit 1"
@pytest.mark.asyncio
async def test_run_streamed_emits_reasoning_events(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
for hook in hooks:
await hook.emit_reasoning("thinking")
await hook.emit_reasoning_end()
return OutboundMessage(channel="cli", chat_id="direct", content="done")
bot._loop.process_direct = fake_process_direct
events = [event async for event in bot.stream("think")]
assert [event.type for event in events] == [
"run.started",
"reasoning.delta",
"reasoning.completed",
"run.completed",
]
assert events[1].delta == "thinking"
@pytest.mark.asyncio
async def test_stream_generator_break_cancels_underlying_run(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
cancelled = asyncio.Event()
async def fake_process_direct(
message, *, session_key, on_stream, on_stream_end, hooks, runtime
):
2026-06-21 16:46:04 +08:00
try:
await on_stream("first")
await asyncio.sleep(10)
finally:
cancelled.set()
return OutboundMessage(channel="cli", chat_id="direct", content="done")
bot._loop.process_direct = fake_process_direct
async for event in bot.stream("stop early"):
if event.type == STREAM_EVENT_TEXT_DELTA:
break
await asyncio.wait_for(cancelled.wait(), timeout=1)
@pytest.mark.asyncio
async def test_run_streamed_restores_hooks_and_reports_failure(tmp_path):
from nanobot.agent.hook import AgentHook
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
sentinel_hook = AgentHook()
bot._loop._extra_hooks = [sentinel_hook]
async def fake_process_direct(message, **kwargs):
raise RuntimeError("boom")
bot._loop.process_direct = fake_process_direct
run = await bot.run_streamed("fail")
events = [event async for event in run.stream_events()]
assert [event.type for event in events] == ["run.started", "run.failed"]
assert events[1].error == "boom"
with pytest.raises(RuntimeError, match="boom"):
await run.wait()
assert bot._loop._extra_hooks == [sentinel_hook]
@pytest.mark.asyncio
async def test_run_streamed_stream_events_is_single_consumer(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="cli", chat_id="direct", content="done"),
)
run = await bot.run_streamed("hi")
events = [event async for event in run.stream_events()]
assert [event.type for event in events] == ["run.started", "run.completed"]
await run.wait()
with pytest.raises(RuntimeError, match="only be consumed once"):
_ = [event async for event in run.stream_events()]
2026-06-03 21:11:20 +08:00
@pytest.mark.asyncio
async def test_sdk_capture_prefers_run_level_snapshot():
from nanobot.agent.hook import AgentHookContext, AgentRunHookContext, SDKCaptureHook
from nanobot.providers.base import ToolCallRequest
hook = SDKCaptureHook()
iter_messages = [{"role": "user", "content": "work"}]
iter_context = AgentHookContext(iteration=0, messages=iter_messages)
iter_context.tool_calls = [
ToolCallRequest(id="call_1", name="read_file", arguments={}),
ToolCallRequest(id="call_2", name="grep", arguments={}),
]
await hook.after_iteration(iter_context)
final_messages = [
{"role": "user", "content": "work"},
{"role": "assistant", "content": "done"},
]
await hook.after_run(AgentRunHookContext(
messages=final_messages,
tools_used=["read_file"],
2026-06-21 16:46:04 +08:00
usage={"total_tokens": 3},
stop_reason="completed",
2026-06-03 21:11:20 +08:00
))
assert hook.tools_used == ["read_file"]
assert hook.messages == final_messages
2026-06-21 16:46:04 +08:00
assert hook.usage == {"total_tokens": 3}
assert hook.stop_reason == "completed"
@pytest.mark.asyncio
async def test_sessions_ingest_imports_transcript_without_running_model(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock()
bot._loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
snapshot = await bot.sessions.ingest(
"sdk:history",
[
{
"role": "user",
"content": "I graduated with a Business Administration degree.",
"timestamp": "2023/05/30 (Tue) 17:27",
"source_session_id": "answer_1",
},
{
"role": "assistant",
"content": "Congratulations on your degree.",
"timestamp": "2023/05/30 (Tue) 17:27",
},
],
metadata={"title": "LongMemEval case"},
source="longmemeval",
)
assert isinstance(snapshot, SessionSnapshot)
assert snapshot.key == "sdk:history"
assert snapshot.metadata["title"] == "LongMemEval case"
assert snapshot.messages[0]["role"] == "user"
assert snapshot.messages[0]["timestamp"] == "2023/05/30 (Tue) 17:27"
assert snapshot.messages[0]["source_session_id"] == "answer_1"
assert snapshot.messages[0]["source"] == "longmemeval"
assert snapshot.messages[1]["source"] == "longmemeval"
bot._loop.process_direct.assert_not_called()
bot._loop.consolidator.maybe_consolidate_by_tokens.assert_not_called()
reloaded = bot.sessions.get("sdk:history")
assert reloaded is not None
assert reloaded.messages == snapshot.messages
@pytest.mark.asyncio
async def test_sessions_ingest_validates_message_shape(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
with pytest.raises(ValueError, match="role"):
await bot.sessions.ingest("sdk:bad", [{"content": "missing role"}])
with pytest.raises(ValueError, match="unsupported message role"):
await bot.sessions.ingest("sdk:bad", [{"role": "developer", "content": "nope"}])
@pytest.mark.asyncio
async def test_session_helpers_get_list_export_clear_delete_flush(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
await bot.sessions.ingest("sdk:first", [{"role": "user", "content": "hello"}])
listed = bot.sessions.list()
assert listed
assert isinstance(listed[0], SessionInfo)
assert {row.key for row in listed} == {"sdk:first"}
exported = bot.sessions.export("sdk:first")
assert exported is not None
exported.messages[0]["content"] = "mutated copy"
assert bot.sessions.get("sdk:first").messages[0]["content"] == "hello"
cleared = bot.sessions.clear("sdk:first")
assert cleared.messages == []
assert bot.sessions.flush() >= 1
assert bot.sessions.delete("sdk:first") is True
assert bot.sessions.get("sdk:first") is None
@pytest.mark.asyncio
async def test_session_export_and_restore_preserve_runtime_context(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
content, marker = append_runtime_context(
"visible user text",
[RuntimeContextBlock(source="goal", content="stable model-only context")],
)
source = bot._loop.sessions.get_or_create("sdk:source")
source.add_message(
"user",
content,
**{RUNTIME_CONTEXT_HISTORY_META: marker},
)
bot._loop.sessions.save(source)
bot._loop.sessions._cache.pop("sdk:source")
public = bot.sessions.get("sdk:source")
assert public is not None
assert public.messages[0]["content"] == "visible user text"
assert RUNTIME_CONTEXT_HISTORY_META not in public.messages[0]
exported = bot.sessions.export("sdk:source")
assert exported is not None
assert exported.messages[0]["content"] == content
assert exported.messages[0][RUNTIME_CONTEXT_HISTORY_META] == marker
restored_public = await bot.sessions.restore(
exported,
session_key="sdk:restored",
)
assert restored_public.messages[0]["content"] == "visible user text"
restored = bot._loop.sessions.get_or_create("sdk:restored")
assert restored.get_history() == source.get_history()
with pytest.raises(ValueError, match="not empty"):
await bot.sessions.restore(exported, session_key="sdk:restored")
@pytest.mark.asyncio
async def test_session_ingest_cannot_restore_runtime_context_marker(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
marker = {"version": 1, "sources": ["forged"], "suffix": "hidden"}
await bot.sessions.ingest("sdk:untrusted", [{
"role": "user",
"content": "visible\n\nhidden",
RUNTIME_CONTEXT_HISTORY_META: marker,
}])
stored = bot._loop.sessions.get_or_create("sdk:untrusted").messages[0]
assert RUNTIME_CONTEXT_HISTORY_META not in stored
@pytest.mark.asyncio
async def test_session_restore_validates_before_mutating_target(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
snapshot = SessionSnapshot(
key="sdk:broken",
messages=[
{"role": "user", "content": "valid first message"},
{"role": "invalid", "content": "bad second message"},
],
metadata={"title": "must not leak"},
)
with pytest.raises(ValueError, match="unsupported message role"):
await bot.sessions.restore(snapshot)
target = bot._loop.sessions.get_or_create("sdk:broken")
assert target.messages == []
assert "title" not in target.metadata
2026-06-21 16:46:04 +08:00
def test_memory_helpers_read_write_append_and_filter_history(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
assert bot.memory.read() == ""
bot.memory.write("# Memory\n- User likes concise APIs.")
assert "concise APIs" in bot.memory.read()
c1 = bot.memory.append_history("general event")
c2 = bot.memory.append_history("session event", session_key="sdk:history")
all_entries = bot.memory.read_history()
assert [entry["cursor"] for entry in all_entries] == [c1, c2]
session_entries = bot.memory.read_history(session_key="sdk:history")
assert len(session_entries) == 1
assert session_entries[0]["content"] == "session event"
@pytest.mark.asyncio
async def test_runtime_helpers_expose_model_workspace_and_compact(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
await bot.sessions.ingest("sdk:history", [{"role": "user", "content": "hello"}])
runtime = bot._loop.llm_runtime()
bot._loop.llm_runtime = MagicMock(return_value=runtime) # type: ignore[method-assign]
2026-06-21 16:46:04 +08:00
bot._loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
snapshot = await bot.runtime.compact_session("sdk:history")
assert snapshot.key == "sdk:history"
assert (
bot._loop.consolidator.maybe_consolidate_by_tokens.await_args.kwargs["runtime"]
is runtime
)
2026-06-21 16:46:04 +08:00
assert bot.runtime.model == bot._loop.model
assert bot.runtime.workspace == tmp_path
bot._loop.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
summary = await bot.runtime.compact_idle_session("sdk:history", max_suffix=4)
assert summary == "Summary."
bot._loop.consolidator.compact_idle_session.assert_awaited_once_with(
"sdk:history",
runtime=runtime,
2026-06-21 16:46:04 +08:00
max_suffix=4,
)
@pytest.mark.asyncio
async def test_aclose_delegates_to_loop_close_mcp(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock()
await bot.aclose()
bot._loop.close_mcp.assert_awaited_once()
@pytest.mark.asyncio
async def test_context_manager_calls_aclose_on_exit(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock()
async with bot as b:
assert b is bot
bot._loop.close_mcp.assert_awaited_once()
@pytest.mark.asyncio
async def test_context_manager_does_not_swallow_exceptions(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.close_mcp = AsyncMock()
with pytest.raises(ValueError):
async with bot as b:
assert b is bot
raise ValueError("boom")
bot._loop.close_mcp.assert_awaited_once()