Merge origin/main into feat/resource-links

This commit is contained in:
chengyongru
2026-07-30 10:18:05 +08:00
269 changed files with 20077 additions and 3922 deletions
+208
View File
@@ -0,0 +1,208 @@
import asyncio
import base64
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
from nanobot.providers.base import LLMResponse
from nanobot.utils.document import reference_non_image_attachments
def _make_loop(
workspace: Path,
channels_config: ChannelsConfig | None = None,
) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok"))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=workspace,
model="test-model",
channels_config=channels_config,
)
def _turn_context(loop: AgentLoop, msg: InboundMessage) -> TurnContext:
return TurnContext(
msg=msg,
session_key=f"{msg.channel}:{msg.chat_id}",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, f"{msg.channel}:{msg.chat_id}"),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("extract_document_text", [True, False])
async def test_document_attachment_is_referenced_and_read_on_demand(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
extract_document_text: bool,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
media_dir = tmp_path / "media"
media_dir.mkdir()
csv_path = media_dir / "report.csv"
csv_path.write_text("name,value\nnanobot,1", encoding="utf-8")
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media_dir)
loop = _make_loop(
workspace,
ChannelsConfig(extract_document_text=extract_document_text),
)
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="c",
content="import this report",
media=[str(csv_path)],
)
ctx = _turn_context(loop, msg)
await loop._restore_turn(ctx)
assert ctx.msg.content == f"import this report\n\n[Attachment: {csv_path}]"
assert "name,value" not in ctx.msg.content
assert ctx.msg.media == []
read_tool = ReadFileTool(workspace=workspace, allowed_dir=workspace)
result = await read_tool.execute(path=str(csv_path))
assert "1| name,value" in result
assert "2| nanobot,1" in result
@pytest.mark.asyncio
async def test_document_reference_survives_session_reload(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
doc_path = tmp_path / "report.csv"
doc_path.write_text("name,value", encoding="utf-8")
loop = _make_loop(workspace)
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("interrupt")) # type: ignore[method-assign]
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="persisted-attachment",
content="review this",
media=[str(doc_path)],
)
with pytest.raises(RuntimeError, match="interrupt"):
await loop._process_message(msg)
session_key = "websocket:persisted-attachment"
loop.sessions.invalidate(session_key)
persisted = loop.sessions.get_or_create(session_key)
assert [message["role"] for message in persisted.messages] == ["user"]
assert persisted.messages[0]["content"] == (
f"review this\n\n[Attachment: {doc_path.resolve()}]"
)
assert "media" not in persisted.messages[0]
@pytest.mark.asyncio
async def test_pending_document_attachment_keeps_body_out_of_prompt(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
doc_path = tmp_path / "followup.txt"
doc_path.write_text("Do not inject this file body", encoding="utf-8")
captured_messages: list[list[dict]] = []
call_count = 0
async def chat_with_retry(*, messages: list[dict], **kwargs: object) -> LLMResponse:
nonlocal call_count
call_count += 1
captured_messages.append([dict(message) for message in messages])
return LLMResponse(content=f"answer-{call_count}", tool_calls=[], usage={})
loop = _make_loop(workspace)
loop.provider.chat_with_retry = chat_with_retry
loop.tools.get_definitions = MagicMock(return_value=[])
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
await pending_queue.put(
InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="check this",
media=[str(doc_path)],
)
)
final_content, _, _, _, had_injections = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
pending_queue=pending_queue,
)
assert final_content == "answer-2"
assert had_injections is True
injected_user_content = [
message["content"]
for message in captured_messages[-1]
if message.get("role") == "user" and isinstance(message.get("content"), str)
][-1]
assert "check this" in injected_user_content
assert f"[Attachment: {doc_path}]" in injected_user_content
assert "Do not inject this file body" not in injected_user_content
def test_attachment_references_still_preserve_images(tmp_path: Path) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.txt"
doc_path.write_text("manual extraction target", encoding="utf-8")
content, media = reference_non_image_attachments(
"review these",
[str(image_path), str(doc_path)],
)
assert media == [str(image_path)]
assert f"[Attachment: {doc_path}]" in content
assert "manual extraction target" not in content
def test_attachment_references_canonicalize_existing_relative_paths(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.csv"
doc_path.write_text("name,value", encoding="utf-8")
monkeypatch.chdir(tmp_path)
content, media = reference_non_image_attachments(
"review these",
[image_path.name, doc_path.name],
)
assert media == [str(image_path.resolve())]
assert f"[Attachment: {doc_path.resolve()}]" in content
+35
View File
@@ -75,6 +75,41 @@ def _tool_round(call_id: str) -> list[dict]:
class TestConsolidatorSummarize:
async def test_archive_prompt_includes_media_breadcrumb(
self, consolidator, mock_provider, store, runtime
):
path = "/home/user/.nanobot/media/websocket/upload_photo.png"
summary = "User uploaded a photo."
mock_provider.chat_with_retry.return_value = MagicMock(
content=summary,
finish_reason="stop",
)
result = await consolidator.archive(
[{"role": "user", "content": "please inspect this", "media": [path]}],
runtime=runtime,
)
prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
entries = store.read_unprocessed_history(since_cursor=0)
assert f"[image: {path}]" in prompt
assert result == summary
assert [entry["content"] for entry in entries] == [summary]
def test_format_messages_keeps_media_only_user_turn(self):
path = "/home/user/.nanobot/media/websocket/clip.mp4"
formatted = MemoryStore._format_messages([
{
"role": "user",
"content": "",
"media": [path],
"timestamp": "2026-07-27",
}
])
assert formatted == f"[2026-07-27] USER: [image: {path}]"
async def test_archive_excludes_model_only_runtime_context(
self, consolidator, mock_provider, runtime
):
+36 -7
View File
@@ -245,38 +245,38 @@ class TestBundledToolContract:
# ---------------------------------------------------------------------------
# _build_user_content
# build_user_content
# ---------------------------------------------------------------------------
class TestBuildUserContent:
def test_no_media_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", None)
result = builder.build_user_content("hello", None)
assert result == "hello"
def test_empty_media_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [])
result = builder.build_user_content("hello", [])
assert result == "hello"
def test_nonexistent_media_file_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", ["/nonexistent/image.png"])
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
assert result == "hello"
def test_non_image_file_returns_string(self, tmp_path):
txt = tmp_path / "doc.txt"
txt.write_text("not an image", encoding="utf-8")
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(txt)])
result = builder.build_user_content("hello", [str(txt)])
assert result == "hello"
def test_valid_image_returns_list(self, tmp_path):
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(png)])
result = builder.build_user_content("hello", [str(png)])
assert isinstance(result, list)
assert len(result) == 2
assert result[0]["type"] == "image_url"
@@ -288,7 +288,7 @@ class TestBuildUserContent:
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(png)])
result = builder.build_user_content("hello", [str(png)])
assert "_meta" in result[0]
assert "path" in result[0]["_meta"]
@@ -467,6 +467,35 @@ class TestBuildMessages:
assert "user-only runtime context" not in messages[-1]["content"]
assert "_meta" not in messages[-1]
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
skill_dir = tmp_path / "skills" / "review"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: review\n"
"description: Review changes.\n"
"---\n\n"
"# Review workflow\n\nFollow the unique review checklist.",
encoding="utf-8",
)
builder = _builder(tmp_path)
messages = builder.build_messages([], "Please $review this patch and use $review carefully.")
system_prompt = messages[0]["content"]
assert "# Active Skills" in system_prompt
assert "### Skill: review" in system_prompt
assert "Follow the unique review checklist." in system_prompt
assert system_prompt.count("### Skill: review") == 1
assert messages[-1]["content"] == (
"Please $review this patch and use $review carefully."
)
def test_unknown_skill_reference_does_not_change_active_skills(self, tmp_path):
messages = _builder(tmp_path).build_messages([], "Keep the shell literal $HOME.")
assert "# Active Skills" not in messages[0]["content"]
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages([], "hello", channel="cli")
@@ -1,176 +0,0 @@
import asyncio
import base64
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
from nanobot.providers.base import LLMResponse
from nanobot.utils.document import reference_non_image_attachments
def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok"))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
channels_config=channels_config,
)
@pytest.mark.asyncio
async def test_restore_turn_extracts_documents_by_default(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
loop = _make_loop(tmp_path)
doc_path = tmp_path / "report.txt"
doc_path.write_text("Quarterly revenue is $5M", encoding="utf-8")
calls: list[tuple[str, list[str]]] = []
def fake_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
calls.append((content, media))
return f"{content}\n\n[File: report.txt]\nQuarterly revenue is $5M", []
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fake_extract_documents)
msg = InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="summarize",
media=[str(doc_path)],
)
ctx = TurnContext(
msg=msg,
session_key="cli:c",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
await loop._restore_turn(ctx)
assert calls == [("summarize", [str(doc_path)])]
assert "Quarterly revenue" in ctx.msg.content
assert ctx.msg.media == []
@pytest.mark.asyncio
async def test_restore_turn_references_documents_when_extraction_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
loop = _make_loop(tmp_path, ChannelsConfig(extract_document_text=False))
doc_path = tmp_path / "report.txt"
doc_path.write_text("Quarterly revenue is $5M", encoding="utf-8")
def fail_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
raise AssertionError("document extraction should be disabled")
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fail_extract_documents)
msg = InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="summarize",
media=[str(doc_path)],
)
ctx = TurnContext(
msg=msg,
session_key="cli:c",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
await loop._restore_turn(ctx)
assert "Quarterly revenue" not in ctx.msg.content
assert f"[Attachment: {doc_path}]" in ctx.msg.content
assert ctx.msg.media == []
@pytest.mark.asyncio
async def test_pending_followup_references_documents_when_extraction_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
doc_path = tmp_path / "followup.txt"
doc_path.write_text("Do not inject this file body", encoding="utf-8")
captured_messages: list[list[dict]] = []
call_count = {"n": 0}
async def chat_with_retry(*, messages: list[dict], **kwargs: object) -> LLMResponse:
call_count["n"] += 1
captured_messages.append([dict(message) for message in messages])
return LLMResponse(content=f"answer-{call_count['n']}", tool_calls=[], usage={})
loop = _make_loop(tmp_path, ChannelsConfig(extract_document_text=False))
loop.provider.chat_with_retry = chat_with_retry
loop.tools.get_definitions = MagicMock(return_value=[])
def fail_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
raise AssertionError("document extraction should be disabled")
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fail_extract_documents)
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
await pending_queue.put(
InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="check this",
media=[str(doc_path)],
)
)
final_content, _, _, _, had_injections = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
pending_queue=pending_queue,
)
assert final_content == "answer-2"
assert had_injections is True
injected_user_content = [
message["content"]
for message in captured_messages[-1]
if message.get("role") == "user" and isinstance(message.get("content"), str)
][-1]
assert "check this" in injected_user_content
assert f"[Attachment: {doc_path}]" in injected_user_content
assert "Do not inject this file body" not in injected_user_content
def test_document_extraction_disabled_still_preserves_images(tmp_path: Path) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.txt"
doc_path.write_text("manual extraction target", encoding="utf-8")
content, media = reference_non_image_attachments(
"review these",
[str(image_path), str(doc_path)],
)
assert media == [str(image_path)]
assert f"[Attachment: {doc_path}]" in content
@@ -7,8 +7,11 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket.runtime import WebSocketChannel
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.session import webui_turns as wth
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
def _make_loop(tmp_path):
@@ -32,6 +35,7 @@ def _make_loop(tmp_path):
sessions=loop.sessions,
schedule_background=lambda coro: loop._schedule_background(coro),
).subscribe(loop.runtime_events)
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
loop.tools.get_definitions = MagicMock(return_value=[])
return loop
@@ -39,29 +43,51 @@ def _make_loop(tmp_path):
@pytest.mark.asyncio
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
loop = _make_loop(tmp_path)
response = await loop.process_direct(
"deliver reminder",
session_key="cron:reminder-1",
channel="websocket",
chat_id="chat-1",
gateway = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
loop.bus,
gateway=gateway,
)
assert response is not None
assert response.content == "done"
try:
response = await loop.process_direct(
"deliver reminder",
session_key="cron:reminder-1",
channel="websocket",
chat_id="chat-1",
)
events = []
while loop.bus.outbound_size:
events.append(await loop.bus.consume_outbound())
assert response is not None
assert response.content == "done"
statuses = [
event.event
for event in events
if isinstance(event.event, GoalStatusEvent)
]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
events = []
while loop.bus.outbound_size:
event = await loop.bus.consume_outbound()
events.append(event)
await channel.send(event)
status_messages = [
event
for event in events
if isinstance(event.event, GoalStatusEvent)
]
statuses = [event.event for event in status_messages]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
owners = {
event.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
for event in status_messages
}
assert len(owners) == 1
assert wth.websocket_turn_wall_started_at("chat-1") is None
assert "chat-1" not in wth._WEBSOCKET_ACTIVE_TURNS
finally:
wth._WEBSOCKET_ACTIVE_TURNS.clear()
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
wth._WEBSOCKET_TURN_IDS.clear()
wth._WEBSOCKET_TURN_OWNERS.clear()
@pytest.mark.asyncio
+11 -1
View File
@@ -28,7 +28,10 @@ from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
def _make_loop(tmp_path: Path) -> AgentLoop:
@@ -903,6 +906,12 @@ class TestToolEventProgress:
turn_id = turn_ids.pop()
assert isinstance(turn_id, str)
assert turn_id.startswith("subagent:")
owners = {
message.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
for message in visible_events
}
assert len(owners) == 1
assert isinstance(owners.pop(), str)
assert all(
(message.channel, message.chat_id) == ("websocket", "chat-a")
and message.metadata.get("webui") is True
@@ -910,6 +919,7 @@ class TestToolEventProgress:
and set(message.metadata) <= {
"webui",
"_wants_stream",
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
"latency_ms",
}
+3 -4
View File
@@ -713,10 +713,9 @@ def test_unified_session_route_ignores_non_user_destinations(
assert session.metadata[LAST_CHANNEL_METADATA_KEY] == "telegram:existing"
# 1x1 PNG used by the media-persistence tests. ``extract_documents`` runs
# at the top of ``_process_message`` and filters ``msg.media`` down to
# paths that magic-byte-sniff as images, so the test fixture needs real
# bytes on disk (not just placeholder paths).
# 1x1 PNG used by the media-persistence tests. Attachment preparation filters
# ``msg.media`` down to paths that magic-byte-sniff as images, so the test
# fixture needs real bytes on disk (not just placeholder paths).
_PNG_1X1 = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
+9 -11
View File
@@ -308,19 +308,17 @@ class TestAppendHistoryHardCap:
entry = store.read_unprocessed_history(since_cursor=0)[0]
assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50
def test_oversize_warning_is_emitted_once(self, store, caplog):
def test_oversize_warning_is_emitted_once(self, store, monkeypatch):
"""Repeated oversized writes should warn only on the first occurrence."""
from loguru import logger as loguru_logger
records: list[str] = []
handler_id = loguru_logger.add(lambda m: records.append(m), level="WARNING")
try:
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1)
store.append_history(huge)
store.append_history(huge)
store.append_history(huge)
finally:
loguru_logger.remove(handler_id)
monkeypatch.setattr(
"nanobot.agent.memory.logger.warning",
lambda message, *args: records.append(message.format(*args)),
)
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1)
store.append_history(huge)
store.append_history(huge)
store.append_history(huge)
oversize_warnings = [r for r in records if "exceeds" in r and "chars" in r]
assert len(oversize_warnings) == 1
+3 -2
View File
@@ -891,7 +891,8 @@ def test_drop_malformed_tool_calls_trims_response():
tool_calls=[
ToolCallRequest(id="1", name=None, arguments={}),
ToolCallRequest(id="2", name="", arguments={}),
ToolCallRequest(id="3", name="read_file", arguments={}),
ToolCallRequest(id="3", name={"unexpected": "object"}, arguments={}),
ToolCallRequest(id="4", name="read_file", arguments={}),
],
finish_reason="tool_calls",
)
@@ -899,7 +900,7 @@ def test_drop_malformed_tool_calls_trims_response():
assert [tc.name for tc in response.tool_calls] == ["read_file"]
assert response.finish_reason == "tool_calls"
assert response.should_execute_tools is True
assert dropped == 2
assert dropped == 3
assert all_dropped is False
assert orig == "tool_calls"
+20
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -8,6 +9,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeModelChanged
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import GenerationSettings
@@ -127,6 +129,24 @@ def test_llm_runtime_surfaces_invalidated_config_errors(tmp_path: Path) -> None:
loop.llm_runtime()
def test_provider_snapshot_missing_env_reports_explicit_config_path(
tmp_path: Path,
monkeypatch,
) -> None:
name = "NANOBOT_TEST_REFRESH_MISSING_KEY"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "custom.json"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_provider_snapshot(config_path)
assert exc_info.value.path == config_path
def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
base_provider = _provider("base-model")
fast_provider = _provider("fast-model")
+31
View File
@@ -387,6 +387,37 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
assert "beta" in always
def test_explicit_skill_references_resolve_available_enabled_names_in_order(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "ws"
skills_root = workspace / "skills"
skills_root.mkdir(parents=True)
_write_skill(skills_root, "alpha", body="# Alpha")
_write_skill(skills_root, "beta", body="# Beta")
_write_skill(
skills_root,
"blocked",
metadata_json={"requires": {"env": ["MISSING_SKILL_TEST_ENV"]}},
body="# Blocked",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
monkeypatch.delenv("MISSING_SKILL_TEST_ENV", raising=False)
loader = SkillsLoader(
workspace,
builtin_skills_dir=builtin,
disabled_skills={"beta"},
)
invoked = loader.get_explicitly_invoked_skills(
"Use $alpha, then $unknown, $alpha again, $beta, and $blocked."
)
assert invoked == ["alpha"]
# -- multiline description tests (YAML folded > and literal |) -----------------
+3 -1
View File
@@ -73,7 +73,9 @@ class TestHandleStop:
task = asyncio.create_task(slow_task())
await asyncio.sleep(0)
loop._active_tasks["test:c1"] = {task}
active_tasks = {task}
loop._active_tasks["test:c1"] = active_tasks
task.add_done_callback(active_tasks.discard)
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
+141 -1
View File
@@ -1,12 +1,147 @@
from pathlib import Path
import pytest
from nanobot.agent.turn_delivery import TurnDeliveryFactory
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
def test_websocket_lifecycles_get_distinct_internal_owners(tmp_path: Path) -> None:
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
first_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-a",
content="first",
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
)
second_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-a",
content="second",
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
)
first = factory.create(first_msg, first_msg.session_key)
second = factory.create(second_msg, second_msg.session_key)
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
assert first_owner == first.delivery_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
assert first_owner != second_owner
assert first_owner != "attacker-reused-owner"
assert second_owner != "attacker-reused-owner"
assert WEBUI_TURN_METADATA_KEY not in first.lifecycle_message.metadata
assert first_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == first_owner
assert second_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == second_owner
def test_websocket_lifecycle_reuses_registered_ingress_owner(tmp_path: Path) -> None:
from nanobot.session import webui_turns as wth
owner = wth.register_queued_websocket_turn_if_idle("chat-queued", "turn-queued")
assert owner is not None
msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-queued",
content="queued",
metadata={
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
WEBUI_TURN_METADATA_KEY: "turn-queued",
},
)
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
try:
delivery = factory.create(msg, msg.session_key)
assert delivery.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
assert msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
finally:
wth.clear_websocket_turn_if_current("chat-queued", owner)
@pytest.mark.asyncio
async def test_same_chat_different_sessions_restore_previous_active_projection(
tmp_path: Path,
) -> None:
from unittest.mock import AsyncMock, MagicMock
from nanobot.session import webui_turns as wth
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
first_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="shared-chat",
content="first",
metadata={WEBUI_TURN_METADATA_KEY: "turn-first"},
session_key_override="websocket:session-first",
)
second_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="shared-chat",
content="second",
metadata={WEBUI_TURN_METADATA_KEY: "turn-second"},
session_key_override="websocket:session-second",
)
first = factory.create(first_msg, first_msg.session_key)
second = factory.create(second_msg, second_msg.session_key)
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
bus = MagicMock()
bus.publish_outbound = AsyncMock()
try:
await wth.publish_turn_run_status(
bus,
first.lifecycle_message,
"running",
started_at=100.0,
)
await wth.publish_turn_run_status(
bus,
second.lifecycle_message,
"running",
started_at=200.0,
)
assert wth.websocket_turn_wall_started_at("shared-chat") == 200.0
assert wth.websocket_turn_id("shared-chat") == "turn-second"
assert wth.clear_websocket_turn_if_current("shared-chat", second_owner) is True
assert wth.websocket_turn_wall_started_at("shared-chat") == 100.0
assert wth.websocket_turn_id("shared-chat") == "turn-first"
assert wth._WEBSOCKET_TURN_OWNERS["shared-chat"] == first_owner
assert wth.clear_websocket_turn_if_current("shared-chat", first_owner) is True
assert wth.websocket_turn_wall_started_at("shared-chat") is None
finally:
wth._WEBSOCKET_ACTIVE_TURNS.pop("shared-chat", None)
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("shared-chat", None)
wth._WEBSOCKET_TURN_IDS.pop("shared-chat", None)
wth._WEBSOCKET_TURN_OWNERS.pop("shared-chat", None)
def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> None:
@@ -45,6 +180,7 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
assert set(first_visible_route.metadata) == {
"webui",
"_wants_stream",
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
}
assert first_visible_route.metadata["webui"] is True
@@ -54,6 +190,10 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
assert first_turn_id.startswith("subagent:")
assert second_turn_id.startswith("subagent:")
assert first_turn_id != second_turn_id
assert (
first_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
!= second_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
)
assert msg.metadata == {
"injected_event": "subagent_result",
"subagent_task_id": "sub-1",
+22
View File
@@ -14,6 +14,23 @@ class RecordingHook(AgentHook):
self._events.append(f"{self._label}:{context.iteration}")
def test_turn_hook_context_preserves_legacy_positional_arguments(tmp_path) -> None:
context = AgentTurnHookContext(
None,
tmp_path,
"sdk",
"chat-a",
"message-1",
"sdk:chat-a",
{"trusted": True},
True,
)
assert context.metadata == {"trusted": True}
assert context.ephemeral is True
assert context.attributes == {}
@pytest.mark.asyncio
async def test_turn_hook_builder_runs_progress_hook_before_extra_hooks() -> None:
events: list[str] = []
@@ -65,6 +82,7 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
session_key="websocket:chat-1",
workspace=tmp_path,
metadata={"source": "test"},
attributes={"tenant": "acme"},
registered_hook_factories=[factory("registered_factory")],
registered_hooks=[RecordingHook(events, "registered")],
turn_hook_factories=[factory("turn_factory")],
@@ -92,6 +110,10 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
{"source": "test"},
{"source": "test"},
]
assert [context.attributes for context in captured] == [
{"tenant": "acme"},
{"tenant": "acme"},
]
@pytest.mark.asyncio
+31
View File
@@ -6,6 +6,7 @@ from nanobot.bus.runtime_events import (
RuntimeEventContext,
RuntimeEventPublisher,
RuntimeModelChanged,
SessionTurnPersisted,
SessionTurnStarted,
TurnCompleted,
TurnRunStatusChanged,
@@ -120,3 +121,33 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N
assert isinstance(second, TurnCompleted)
assert second.latency_ms is None
assert second.runtime is None
@pytest.mark.asyncio
async def test_runtime_event_publisher_emits_persisted_turn_attributes() -> None:
bus = RuntimeEventBus()
seen: list[object] = []
publisher = RuntimeEventPublisher(bus)
msg = InboundMessage(
channel="sdk",
sender_id="alice",
chat_id="chat-a",
content="hello",
metadata={"internal": "routing"},
)
bus.subscribe(seen.append, SessionTurnPersisted)
await publisher.session_turn_persisted(
msg,
"sdk:chat-a",
turn_id="turn-1",
attributes={"tenant": "acme"},
)
event = seen[0]
assert isinstance(event, SessionTurnPersisted)
assert event.context.session_key == "sdk:chat-a"
assert event.context.metadata == {"internal": "routing"}
assert event.context.attributes == {"tenant": "acme"}
assert event.turn_id == "turn-1"
assert event.sender_id == "alice"
+200 -9
View File
@@ -272,14 +272,12 @@ def test_channels_config_has_no_per_channel_fields():
assert cfg.send_tool_hints is True
assert cfg.extract_document_text is True
opted_out = ChannelsConfig.model_validate({"sendToolHints": False})
opted_out = ChannelsConfig.model_validate({
"sendToolHints": False,
"extractDocumentText": False,
})
assert opted_out.send_tool_hints is False
def test_channels_config_extract_document_text_accepts_camel_alias():
cfg = ChannelsConfig.model_validate({"extractDocumentText": False})
assert cfg.extract_document_text is False
assert opted_out.extract_document_text is False
@pytest.mark.parametrize(
@@ -1219,6 +1217,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
async def login(self, force: bool = False) -> bool:
seen["force"] = force
seen["config"] = self.config
seen["bus"] = self.bus
return True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
@@ -1231,6 +1230,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
assert result.exit_code == 0
assert seen["force"] is True
assert isinstance(seen["bus"], MessageBus)
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
@@ -1494,7 +1494,7 @@ def test_repository_dependency_installer_selects_all_channel_manifests(monkeypat
monkeypatch.setattr(dependencies, "discover_plugins", lambda: plugins)
monkeypatch.setattr(
dependencies,
"ensure_enabled_channel_dependencies",
"ensure_repository_channel_dependencies",
lambda names, discovered: prepared.append((names, discovered)) or {},
)
@@ -1502,6 +1502,191 @@ def test_repository_dependency_installer_selects_all_channel_manifests(monkeypat
assert prepared == [(set(plugins), plugins)]
def test_repository_dependency_installer_batches_missing_manifests(monkeypatch):
from nanobot.optional_features import InstallResult
from scripts import install_channel_dependencies as dependencies
plugins = {
"second": ChannelPlugin(
name="second",
display_name="Second",
runtime="missing.second.runtime:SecondChannel",
dependencies=("shared-sdk>=1", "second-sdk>=2"),
),
"first": ChannelPlugin(
name="first",
display_name="First",
runtime="missing.first.runtime:FirstChannel",
dependencies=("first-sdk>=1", "shared-sdk>=1"),
),
"ready": ChannelPlugin(
name="ready",
display_name="Ready",
runtime="missing.ready.runtime:ReadyChannel",
dependencies=("ready-sdk>=1",),
),
}
batch_installed = False
installs: list[tuple[str, list[str]]] = []
def extra_installed(name: str, _requirements: list[str]) -> bool:
return name == "ready" or batch_installed
def install_extra(name: str, requirements: list[str]) -> InstallResult:
nonlocal batch_installed
installs.append((name, requirements))
batch_installed = True
return InstallResult(True, name, ["pip"])
monkeypatch.setattr(dependencies, "extra_installed", extra_installed)
monkeypatch.setattr(dependencies, "install_extra", install_extra)
monkeypatch.setattr(
dependencies,
"ensure_enabled_channel_dependencies",
lambda _names, _plugins: pytest.fail("verified batch must not use the fallback"),
)
failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins)
assert failures == {}
assert installs == [
(
"channel-dependencies",
["first-sdk>=1", "shared-sdk>=1", "second-sdk>=2"],
)
]
def test_repository_dependency_installer_falls_back_after_batch_failure(monkeypatch):
from nanobot.optional_features import InstallResult
from scripts import install_channel_dependencies as dependencies
plugins = {
name: ChannelPlugin(
name=name,
display_name=name.title(),
runtime=f"missing.{name}.runtime:Channel",
dependencies=(f"{name}-sdk>=1",),
)
for name in ("first", "second")
}
fallbacks: list[set[str]] = []
fallback_finished = False
def extra_installed(name: str, _requirements: list[str]) -> bool:
return fallback_finished and name == "first"
def fallback(names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]:
nonlocal fallback_finished
fallbacks.append(names)
fallback_finished = True
return {"second": "install failed"}
monkeypatch.setattr(dependencies, "extra_installed", extra_installed)
monkeypatch.setattr(
dependencies,
"install_extra",
lambda name, _requirements: InstallResult(False, name, ["pip"]),
)
monkeypatch.setattr(
dependencies,
"ensure_enabled_channel_dependencies",
fallback,
)
failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins)
assert failures == {"second": "install failed"}
assert fallbacks == [set(plugins)]
def test_repository_dependency_installer_rechecks_each_channel_after_batch(monkeypatch):
from nanobot.optional_features import InstallResult
from scripts import install_channel_dependencies as dependencies
plugins = {
name: ChannelPlugin(
name=name,
display_name=name.title(),
runtime=f"missing.{name}.runtime:Channel",
dependencies=(f"{name}-sdk>=1",),
)
for name in ("first", "second")
}
batch_finished = False
fallback_finished = False
fallbacks: list[set[str]] = []
def extra_installed(name: str, _requirements: list[str]) -> bool:
if fallback_finished:
return True
if batch_finished:
return name == "second"
return name == "first"
def install_extra(name: str, _requirements: list[str]) -> InstallResult:
nonlocal batch_finished
batch_finished = True
return InstallResult(True, name, ["pip"])
def fallback(names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]:
nonlocal fallback_finished
fallbacks.append(names)
fallback_finished = True
return {}
monkeypatch.setattr(dependencies, "extra_installed", extra_installed)
monkeypatch.setattr(dependencies, "install_extra", install_extra)
monkeypatch.setattr(
dependencies,
"ensure_enabled_channel_dependencies",
fallback,
)
failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins)
assert failures == {}
assert fallbacks == [{"first"}]
def test_repository_dependency_installer_reports_conflict_after_fallback(monkeypatch):
from nanobot.optional_features import InstallResult
from scripts import install_channel_dependencies as dependencies
plugins = {
name: ChannelPlugin(
name=name,
display_name=name.title(),
runtime=f"missing.{name}.runtime:Channel",
dependencies=(f"{name}-sdk>=1",),
)
for name in ("first", "second")
}
fallback_finished = False
def extra_installed(name: str, _requirements: list[str]) -> bool:
return fallback_finished and name == "second"
def fallback(_names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]:
nonlocal fallback_finished
fallback_finished = True
return {}
monkeypatch.setattr(dependencies, "extra_installed", extra_installed)
monkeypatch.setattr(
dependencies,
"install_extra",
lambda name, _requirements: InstallResult(False, name, ["pip"]),
)
monkeypatch.setattr(dependencies, "ensure_enabled_channel_dependencies", fallback)
failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins)
assert failures == {
"first": "Channel dependencies could not be installed. Check gateway logs."
}
def test_repository_dependency_installer_rejects_unknown_channel(monkeypatch, capsys):
from scripts import install_channel_dependencies as dependencies
@@ -1522,7 +1707,7 @@ def test_repository_dependency_installer_propagates_install_failure(monkeypatch,
monkeypatch.setattr(dependencies, "discover_plugins", lambda: {"demo": plugin})
monkeypatch.setattr(
dependencies,
"ensure_enabled_channel_dependencies",
"ensure_repository_channel_dependencies",
lambda _names, _plugins: {"demo": "dependency install failed"},
)
@@ -2390,6 +2575,12 @@ def test_optional_dependency_metadata_for_enable():
]
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
assert deps["olostep"] == ["olostep>=0.1.0; python_version < '3.14'"]
expected_olostep_args = [] if sys.version_info >= (3, 14) else ["olostep>=0.1.0"]
assert optional_features.install_args_for_extra("olostep", deps["olostep"]) == (
expected_olostep_args,
"olostep support",
)
channel_names = {
"dingtalk",
"discord",
+51 -13
View File
@@ -13,6 +13,7 @@ import pytest
from typer.testing import CliRunner
from nanobot.agent.memory import MemoryStore
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.turn_delivery import TurnDeliveryFactory
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli import commands as cli_commands
@@ -35,6 +36,10 @@ from nanobot.webui.metadata import (
runner = CliRunner()
def _without_rendered_line_breaks(output: str) -> str:
return "".join(output.splitlines())
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = {
"webui": True,
@@ -67,6 +72,26 @@ class _StopGatewayError(RuntimeError):
pass
class _GatewayAgentContractStub:
"""Minimal stable AgentLoop surface required by gateway assembly tests."""
tools = ToolRegistry()
@staticmethod
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
return set()
@staticmethod
def pending_local_trigger_ids_for_session(_session_key: str) -> set[str]:
return set()
async def submit_local_trigger_turn(
self,
_msg: InboundMessage,
) -> OutboundMessage | None:
return None
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
class _FakeLoop:
def __init__(self) -> None:
@@ -1904,12 +1929,10 @@ def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapsho
def _patch_webui_provider_ready(monkeypatch) -> None:
provider = _fake_provider()
def _snapshot(config: Config, **_kwargs) -> ProviderSnapshot:
return _test_provider_snapshot(provider, config)
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
monkeypatch.setattr(
"nanobot.providers.factory.validate_provider_setup",
lambda _config: None,
)
def _patch_gateway_ports_free(monkeypatch) -> None:
@@ -1959,6 +1982,10 @@ def _patch_cli_command_runtime(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
monkeypatch.setattr(
"nanobot.cli.commands._provider_setup_error",
lambda _config: None,
)
_patch_gateway_ports_free(monkeypatch)
if message_bus is not None:
@@ -2019,7 +2046,7 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
def register_system_job(self, _job: CronJob) -> None:
raise _StopGatewayError("stop")
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
@@ -2192,6 +2219,9 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
assert result.exit_code == 1
assert missing_env in result.stdout
assert "nanobot status --config" in result.stdout
assert config_file.name in result.stdout
assert "Traceback" not in result.stdout
assert f"${{{missing_env}}}" in config_file.read_text(encoding="utf-8")
@@ -2221,6 +2251,10 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
assert result.exit_code == 1
assert "provider/model setup is incomplete" in result.stdout
assert "Settings → Models" in _without_rendered_line_breaks(result.stdout)
assert "nanobot onboard --wizard" in result.stdout
assert "nanobot status --config" in result.stdout
assert config_file.name in result.stdout
def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path: Path) -> None:
@@ -2644,6 +2678,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
@@ -2681,7 +2716,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
self.on_job = None
seen["cron"] = self
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
@@ -2771,6 +2806,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
@@ -2796,7 +2832,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
def write_run_record(self, run_id: str, record: dict[str, object]) -> None:
seen["run_records"].append((run_id, record))
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
@@ -3014,7 +3050,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
def register_system_job(self, _job) -> None:
return None
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
seen["agent_from_config_kwargs"] = extra
@@ -3080,6 +3116,8 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
kwargs = seen["local_trigger_queue_kwargs"]
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
assert agent_kwargs["resource_view"] is resource_view
refreshed_snapshot = agent_kwargs["provider_snapshot_loader"]()
assert not isinstance(refreshed_snapshot.provider, UnconfiguredProvider)
assert "local_trigger_store" in agent_kwargs
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
assert "bus" not in kwargs
@@ -3266,7 +3304,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
def flush_all(self) -> int:
return 0
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
@@ -3459,7 +3497,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
def flush_all(self) -> int:
return 0
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
@@ -3558,7 +3596,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
def flush_all(self) -> int:
return 0
class _FakeAgentLoop:
class _FakeAgentLoop(_GatewayAgentContractStub):
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
+539
View File
@@ -0,0 +1,539 @@
import json
import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import app
from nanobot.gateway import GatewayRuntime, GatewayStartOptions, GatewayStatus, RuntimeResult
runner = CliRunner()
_ANTHROPIC_BACKEND_CASES = (
("anthropic", "anthropic", "claude-sonnet-4-5", "ANTHROPIC_API_KEY", "Anthropic"),
("kimi_coding", "kimiCoding", "kimi-for-coding", "KIMI_CODING_API_KEY", "Kimi Coding"),
(
"minimax_anthropic",
"minimaxAnthropic",
"MiniMax-M2.7-highspeed",
"MINIMAX_API_KEY",
"MiniMax (Anthropic)",
),
)
def _without_rendered_line_breaks(output: str) -> str:
return "".join(output.splitlines())
def _write_ready_config(config_path, *, channels: dict | None = None) -> None:
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "ollama/llama3.2",
"provider": "ollama",
}
},
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1",
}
},
"channels": channels or {},
}
),
encoding="utf-8",
)
def test_status_reports_ready_provider_and_next_step(tmp_path) -> None:
config_path = tmp_path / "config.json"
_write_ready_config(config_path)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
assert "Ollama:" in result.stdout
assert "Model: ollama/llama3.2" in result.stdout
assert 'nanobot agent -m "Hello!"' in result.stdout
assert "Status does not call the model" in result.stdout
def test_status_validates_bedrock_without_constructing_provider(
tmp_path,
monkeypatch,
) -> None:
from nanobot.providers.bedrock_provider import BedrockProvider
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "bedrock/amazon.nova-lite-v1:0",
"provider": "bedrock",
}
},
"providers": {"bedrock": {"region": "us-east-1"}},
}
),
encoding="utf-8",
)
def _unexpected_init(*_args, **_kwargs) -> None:
pytest.fail("status must not construct a provider client")
monkeypatch.setattr(BedrockProvider, "__init__", _unexpected_init)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
assert "Status does not call the model or verify network access" in result.stdout
@pytest.mark.parametrize(
("provider", "provider_key", "model", "env_name", "label"),
_ANTHROPIC_BACKEND_CASES,
)
def test_status_reports_missing_key_for_anthropic_backends(
tmp_path,
monkeypatch,
provider: str,
provider_key: str,
model: str,
env_name: str,
label: str,
) -> None:
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv(env_name, raising=False)
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": model, "provider": provider}},
"providers": {provider_key: {}},
}
),
encoding="utf-8",
)
result = runner.invoke(app, ["status", "--config", str(config_path)])
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 0
assert f"Agent: ✗ No API key configured for provider '{provider}'." in output
assert f"{label}: not set" in output
assert "provider/model configuration is ready" not in output
assert 'Next: nanobot agent -m "Hello!"' not in output
assert "Settings → Models" in output
@pytest.mark.parametrize(
("provider", "provider_key", "model", "env_name", "label"),
_ANTHROPIC_BACKEND_CASES,
)
def test_status_accepts_resolved_key_for_anthropic_backends(
tmp_path,
monkeypatch,
provider: str,
provider_key: str,
model: str,
env_name: str,
label: str,
) -> None:
monkeypatch.setenv(env_name, "test-api-key")
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": model, "provider": provider}},
"providers": {provider_key: {"apiKey": f"${{{env_name}}}"}},
}
),
encoding="utf-8",
)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
assert f"{label}: ✓" in result.stdout
assert 'nanobot agent -m "Hello!"' in result.stdout
def test_status_reports_missing_provider_with_shortest_setup_routes(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("{}", encoding="utf-8")
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "Agent: ✗" in result.stdout
assert "No provider is configured for model" in result.stdout
assert "Settings → Models" in _without_rendered_line_breaks(result.stdout)
assert "nanobot onboard --wizard" in result.stdout
assert "nanobot status --config" in result.stdout
def test_status_readiness_does_not_validate_channel_configuration(tmp_path) -> None:
config_path = tmp_path / "config.json"
_write_ready_config(
config_path,
channels={"websocket": {"enabled": False, "path": "missing-slash"}},
)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
assert "channels.websocket" not in result.stdout
def test_status_reports_json_location_without_traceback(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("{broken", encoding="utf-8")
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 1
assert "Invalid configuration" in result.stdout
assert "JSON syntax error at line 1, column 2" in result.stdout
assert "Traceback" not in result.stdout
def test_status_reports_field_without_exposing_secret(tmp_path) -> None:
config_path = tmp_path / "config.json"
secret = "should-never-appear"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": [secret]}}}),
encoding="utf-8",
)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 1
assert "providers.openrouter.apiKey" in result.stdout
assert secret not in result.stdout
assert "input_value" not in result.stdout
assert "errors.pydantic.dev" not in result.stdout
def test_status_reports_missing_env_var_at_field(tmp_path, monkeypatch) -> None:
name = "NANOBOT_TEST_STATUS_MISSING"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
encoding="utf-8",
)
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "providers.openrouter.apiKey" in result.stdout
assert name in result.stdout
assert "OpenRouter: not set" in result.stdout
assert "OpenRouter: ✓" not in result.stdout
def test_webui_reports_malformed_environment_config_without_traceback(
tmp_path,
monkeypatch,
) -> None:
config_path = tmp_path / "missing.json"
invalid_value = "sensitive-not-json"
monkeypatch.setenv("NANOBOT_PROVIDERS", invalid_value)
result = runner.invoke(
app,
["webui", "--config", str(config_path), "--yes", "--no-open"],
)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert "Environment-based configuration could not be parsed" in result.stdout
assert "nanobot status --config" in result.stdout
assert invalid_value not in result.stdout
assert not config_path.exists()
@pytest.mark.parametrize(
"args",
[
["webui", "--yes", "--no-open"],
["agent", "--message", "hello"],
],
)
def test_agent_entrypoints_point_invalid_config_to_status(tmp_path, args: list[str]) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("{broken", encoding="utf-8")
result = runner.invoke(app, [*args, "--config", str(config_path)])
assert result.exit_code == 1
assert "Invalid configuration" in result.stdout
assert "nanobot status --config" in result.stdout
assert "Traceback" not in result.stdout
def test_agent_provider_setup_failure_points_to_shortest_routes(tmp_path) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps({"agents": {"defaults": {"workspace": str(workspace)}}}),
encoding="utf-8",
)
result = runner.invoke(
app,
["agent", "--message", "hello", "--config", str(config_path)],
)
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 1
assert "Agent cannot start: No provider is configured for model" in output
assert "Settings → Models" in output
assert "nanobot onboard --wizard" in output
assert "nanobot status --config" in output
assert "Traceback" not in output
assert not workspace.exists()
@pytest.mark.parametrize(
"args",
[
["gateway"],
["gateway", "--background"],
["gateway", "restart"],
],
)
def test_gateway_provider_setup_failure_points_to_shortest_routes_when_webui_disabled(
tmp_path,
monkeypatch,
args: list[str],
) -> None:
config_path = tmp_path / "explicit-gateway-config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace)}},
"channels": {"websocket": {"enabled": False}},
}
),
encoding="utf-8",
)
def unexpected_managed_start(*_args, **_kwargs) -> RuntimeResult:
pytest.fail("provider validation must fail before a managed gateway start")
monkeypatch.setattr(GatewayRuntime, "start_background", unexpected_managed_start)
monkeypatch.setattr(GatewayRuntime, "restart", unexpected_managed_start)
result = runner.invoke(app, [*args, "--config", str(config_path)])
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 1
assert "Gateway cannot start: No provider is configured for model" in output
assert "Settings → Models" in output
assert "nanobot onboard --wizard" in output
assert "nanobot status --config" in output
assert config_path.name in output
assert "Traceback" not in output
assert not workspace.exists()
@pytest.mark.parametrize(
("args", "start_mode"),
[
(["gateway", "--background"], "background"),
(["gateway", "restart"], "restart"),
],
)
@pytest.mark.parametrize("secret_field", ["tokenIssueSecret", "token"])
def test_gateway_missing_provider_managed_start_for_webui_setup(
tmp_path,
monkeypatch,
args: list[str],
start_mode: str,
secret_field: str,
) -> None:
config_path = tmp_path / "explicit-gateway-config.json"
workspace = tmp_path / "workspace"
webui_port = 18776
bootstrap_secret = "must-not-appear-in-gateway-output"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace)}},
"channels": {
"websocket": {
"enabled": True,
"host": "127.0.0.1",
"port": webui_port,
secret_field: bootstrap_secret,
}
},
}
),
encoding="utf-8",
)
started_options: list[tuple[str, GatewayStartOptions]] = []
status = GatewayStatus(
running=True,
pid=12345,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
started_at="2026-07-28T00:00:00Z",
port=18790,
reason="running",
)
def fake_start_background(
_runtime: GatewayRuntime,
options: GatewayStartOptions,
) -> RuntimeResult:
started_options.append(("background", options))
return RuntimeResult(True, "gateway_started_background", status)
def fake_restart(
_runtime: GatewayRuntime,
options: GatewayStartOptions,
*,
timeout_s: int,
) -> RuntimeResult:
assert timeout_s == 20
started_options.append(("restart", options))
return RuntimeResult(True, "gateway_started_background", status)
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
monkeypatch.setattr(
"nanobot.cli.commands.ensure_webui_bundle",
lambda **_kwargs: None,
)
result = runner.invoke(
app,
[*args, "--config", str(config_path)],
)
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 0
assert "Provider/model setup is incomplete: No provider is configured for model" in output
assert "Gateway will start so you can configure a provider and model" in output
assert "WebUI Settings" in output
assert "Models." in output
assert f"WebUI: http://127.0.0.1:{webui_port}" in output
assert f"channels.websocket.{secret_field}" in output
if secret_field == "token":
assert "channels.websocket.tokenIssueSecret" not in output
assert "bootstrapSecret" not in output
assert bootstrap_secret not in output
assert "Gateway cannot start" not in output
assert started_options == [
(
start_mode,
GatewayStartOptions(
port=18790,
config_path=str(config_path.resolve()),
),
)
]
assert not workspace.exists()
def test_gateway_invalid_webui_config_blocks_unconfigured_setup_mode(tmp_path) -> None:
config_path = tmp_path / "invalid-webui-config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace)}},
"channels": {
"websocket": {
"enabled": True,
"port": "not-a-port",
}
},
}
),
encoding="utf-8",
)
result = runner.invoke(app, ["gateway", "--config", str(config_path)])
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 1
assert "Gateway configuration is invalid." in output
assert "channels.websocket.port" in output
assert "Provider/model setup is incomplete" not in output
assert "Traceback" not in output
assert not workspace.exists()
@pytest.mark.parametrize(
("args", "summary", "retry_command"),
[
(
["webui", "--yes", "--no-open"],
"WebUI configuration is invalid.",
"nanobot webui --config",
),
(
["gateway"],
"Gateway configuration is invalid.",
"nanobot gateway --config",
),
],
)
def test_runtime_config_validation_is_redacted_and_actionable(
tmp_path,
args: list[str],
summary: str,
retry_command: str,
) -> None:
config_path = tmp_path / "explicit-runtime-config.json"
workspace = tmp_path / "workspace"
invalid_value = "sensitive-not-a-port"
_write_ready_config(
config_path,
channels={
"websocket": {
"enabled": True,
"port": invalid_value,
}
},
)
data = json.loads(config_path.read_text(encoding="utf-8"))
data["agents"]["defaults"]["workspace"] = str(workspace)
config_path.write_text(json.dumps(data), encoding="utf-8")
result = runner.invoke(app, [*args, "--config", str(config_path)])
output = _without_rendered_line_breaks(result.stdout)
assert result.exit_code == 1
assert summary in output
assert "channels.websocket.port" in output
assert retry_command in output
assert config_path.name in output
assert invalid_value not in output
assert "input_value" not in output
assert "errors.pydantic.dev" not in output
assert "Traceback" not in output
assert not workspace.exists()
def test_status_missing_file_points_to_setup_without_changing_exit_contract(tmp_path) -> None:
config_path = tmp_path / "missing.json"
result = runner.invoke(app, ["status", "--config", str(config_path)])
assert result.exit_code == 0
assert "configuration file not found" in result.stdout
assert "nanobot webui" in result.stdout
assert "nanobot onboard --wizard" in result.stdout
+59 -3
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import pytest
import typer
from rich.console import Console
from typer.testing import CliRunner
@@ -27,6 +28,7 @@ class FakeRuntime:
self.restarted_options: GatewayStartOptions | None = None
self.stop_timeout: int | None = None
self.follow_tail: int | None = None
self.validated_configs: list[Config] = []
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
self.started_options = options
@@ -84,11 +86,15 @@ class FakeServiceInstaller:
)
def _test_app(tmp_path: Path, config: Config | None = None):
def _test_app(
tmp_path: Path,
config: Config | None = None,
startup_error: str | None = None,
):
app = typer.Typer()
fake_runtime = FakeRuntime(tmp_path)
fake_service = FakeServiceInstaller(tmp_path)
run_calls: list[tuple[Config, int | None, str | None]] = []
run_calls: list[tuple[Config, int | None, str | None, str | None]] = []
prepare_calls: list[tuple[Config, str]] = []
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
@@ -99,18 +105,28 @@ def _test_app(tmp_path: Path, config: Config | None = None):
*,
port: int | None = None,
webui_bundle_mode: str | None = None,
unconfigured_provider_error: str | None = None,
) -> None:
run_calls.append((config, port, webui_bundle_mode))
run_calls.append(
(config, port, webui_bundle_mode, unconfigured_provider_error)
)
def prepare_webui_bundle(config: Config, mode: str) -> None:
prepare_calls.append((config, mode))
def validate_startup_config(config: Config) -> str | None:
fake_runtime.validated_configs.append(config)
return startup_error
app.add_typer(
create_gateway_app(
console=Console(),
log_handler_id=0,
load_runtime_config=load_runtime_config,
run_gateway=run_gateway,
validate_startup_config=(
validate_startup_config if startup_error is not None else None
),
runtime_factory=lambda **_kwargs: fake_runtime,
service_factory=lambda: fake_service,
prepare_webui_bundle=prepare_webui_bundle,
@@ -131,6 +147,46 @@ def test_gateway_default_still_runs_foreground(tmp_path):
assert calls[0][2] == "warn"
def test_gateway_foreground_passes_recoverable_provider_error_to_runner(tmp_path):
setup_error = "No provider is configured."
app, _runtime, _service, calls, _prepare_calls = _test_app(
tmp_path,
startup_error=setup_error,
)
result = runner.invoke(app, ["gateway"])
assert result.exit_code == 0
assert len(calls) == 1
assert calls[0][3] == setup_error
assert len(_runtime.validated_configs) == 1
@pytest.mark.parametrize(
("args", "runtime_attribute"),
[
(["gateway", "--background"], "started_options"),
(["gateway", "restart"], "restarted_options"),
],
)
def test_gateway_managed_start_allows_recoverable_provider_error(
tmp_path,
args: list[str],
runtime_attribute: str,
) -> None:
app, fake_runtime, _service, calls, _prepare_calls = _test_app(
tmp_path,
startup_error="No provider is configured.",
)
result = runner.invoke(app, args)
assert result.exit_code == 0
assert calls == []
assert len(fake_runtime.validated_configs) == 1
assert getattr(fake_runtime, runtime_attribute) is not None
def test_gateway_background_starts_detached_runtime(tmp_path):
config = Config()
config.gateway.port = 18792
+2
View File
@@ -231,6 +231,7 @@ def _build_runnable_dream(
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
dream_runtime=lambda: None,
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
return ctx, store
@@ -317,6 +318,7 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
dream_runtime=lambda: None,
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
+26 -1
View File
@@ -2,14 +2,25 @@
from __future__ import annotations
from inspect import Parameter, signature
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.command.builtin import register_builtin_commands
from nanobot.command.builtin import (
builtin_command_starts_agent_turn,
register_builtin_commands,
)
from nanobot.command.router import CommandContext, CommandRouter
def test_command_context_requires_loop_as_keyword_dependency() -> None:
loop_parameter = signature(CommandContext).parameters["loop"]
assert loop_parameter.kind is Parameter.KEYWORD_ONLY
assert loop_parameter.default is Parameter.empty
class TestIsDispatchableCommand:
"""Unit tests for the is_dispatchable_command() predicate."""
@@ -64,6 +75,20 @@ class TestIsDispatchableCommand:
assert not router.is_dispatchable_command("/foo bar")
@pytest.mark.parametrize(
("content", "expected"),
[
("/status", False),
("/history 5", False),
("/goal", False),
("/goal migrate the database", True),
("regular prompt", True),
],
)
def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None:
assert builtin_command_starts_agent_turn(content) is expected
class TestMidTurnCommandDispatchedDirectly:
"""Verify that commands matching is_dispatchable_command() are dispatched
correctly when session=None (the mid-turn path)."""
+129 -2
View File
@@ -2,6 +2,7 @@ import json
import pytest
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import load_config
from nanobot.config.schema import ApiConfig
@@ -12,13 +13,35 @@ def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
assert config.agents.defaults.model
def test_load_config_reports_malformed_environment_safely(
tmp_path,
monkeypatch,
) -> None:
config_path = tmp_path / "missing.json"
invalid_value = "sensitive-not-json"
monkeypatch.setenv("NANOBOT_PROVIDERS", invalid_value)
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
error = exc_info.value
assert error.kind == "invalid_schema"
assert error.path == config_path
assert "complex NANOBOT_* values use valid JSON" in str(error)
assert invalid_value not in str(error)
def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("{broken json", encoding="utf-8")
with pytest.raises(ValueError, match="Failed to load config"):
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
error = exc_info.value
assert error.kind == "invalid_json"
assert "line 1, column 2" in str(error)
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
config_path = tmp_path / "config.json"
@@ -27,9 +50,113 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
encoding="utf-8",
)
with pytest.raises(ValueError, match="Failed to load config"):
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
error = exc_info.value
message = str(error)
assert error.kind == "invalid_schema"
assert "tools.exec.timeout" in message
assert "Must be greater than or equal to 0." in message
assert "input_value" not in message
assert "errors.pydantic.dev" not in message
@pytest.mark.parametrize(
("content", "root_type"),
[("[]", "list"), ("null", "NoneType"), ('"value"', "str")],
)
def test_load_config_rejects_non_object_root(tmp_path, content: str, root_type: str) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(content, encoding="utf-8")
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
error = exc_info.value
assert error.kind == "invalid_root"
assert f"Expected an object, but found {root_type}." in str(error)
def test_load_config_error_does_not_expose_invalid_secret_value(tmp_path) -> None:
config_path = tmp_path / "config.json"
secret = "should-never-appear"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": [secret]}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
assert secret not in str(exc_info.value)
def test_load_config_error_redacts_untrusted_location_parts(tmp_path) -> None:
config_path = tmp_path / "config.json"
secret = "should-never-appear-in-location"
server_name = f"https://user:{secret}@example.test"
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
server_name: {"toolTimeout": "not-a-number"},
}
}
}
),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
message = str(exc_info.value)
assert "tools.mcpServers.<redacted>.toolTimeout" in message
assert server_name not in message
assert secret not in message
def test_load_config_error_does_not_trust_custom_validator_message(tmp_path) -> None:
config_path = tmp_path / "config.json"
secret = "diagnostic-secret-should-not-print"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"thinkingStyle": secret}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
message = str(exc_info.value)
assert "providers.openrouter.thinkingStyle" in message
assert "Value does not satisfy this setting's requirements." in message
assert secret not in message
@pytest.mark.parametrize(
"tools",
[
[],
{"exec": []},
{"my": 1, "myEnabled": True},
],
)
def test_load_config_malformed_legacy_sections_use_structured_error(
tmp_path,
tools: object,
) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": tools}), encoding="utf-8")
with pytest.raises(ConfigLoadError) as exc_info:
load_config(config_path)
error = exc_info.value
assert error.kind == "invalid_schema"
assert "tools" in str(error)
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
+24
View File
@@ -2,10 +2,12 @@ import json
import pytest
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import (
_resolve_env_vars,
load_config,
resolve_config_env_vars,
resolve_env_refs,
save_config,
)
from nanobot.config.schema import Config
@@ -49,6 +51,12 @@ class TestResolveEnvVars:
_resolve_env_vars("${DOES_NOT_EXIST}")
class TestResolveSingleEnvRefs:
@pytest.mark.parametrize("value", [None, 42, True, {"key": "value"}])
def test_non_string_values_pass_through_unchanged(self, value):
assert resolve_env_refs(value) is value
class TestResolveConfig:
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
@@ -66,6 +74,22 @@ class TestResolveConfig:
resolved = resolve_config_env_vars(raw)
assert resolved.providers.groq.api_key == "resolved-key"
def test_missing_env_var_reports_config_field(self, tmp_path, monkeypatch):
name = "NANOBOT_TEST_MISSING_PROVIDER_KEY"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "config.json"
config = Config.model_validate(
{"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}
)
with pytest.raises(ConfigLoadError) as exc_info:
resolve_config_env_vars(config, config_path=config_path)
error = exc_info.value
assert error.kind == "missing_env"
assert "providers.openrouter.apiKey" in str(error)
assert name in str(error)
def test_save_preserves_templates(self, tmp_path, monkeypatch):
monkeypatch.setenv("MY_TOKEN", "real-token")
config_path = tmp_path / "config.json"
+21
View File
@@ -1,7 +1,10 @@
import json
import warnings
import pytest
from nanobot.agent.model_presets import load_model_preset_catalog
from nanobot.config.errors import ConfigLoadError
from nanobot.config.schema import Config
@@ -16,6 +19,24 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
tmp_path,
monkeypatch,
) -> None:
name = "NANOBOT_TEST_CATALOG_MISSING_KEY"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "custom.json"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_model_preset_catalog(config_path)
assert exc_info.value.path == config_path
def test_agent_timezone_rejects_unknown_iana_name() -> None:
with pytest.raises(ValueError, match="unknown timezone"):
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
+11
View File
@@ -65,6 +65,17 @@ def test_load_jobs_accepts_snake_case_schedule_and_run_history(tmp_path) -> None
assert jobs[0].state.run_history[0].duration_ms == 12
def test_cron_job_from_dict_rejects_malformed_run_history() -> None:
with pytest.raises(TypeError):
CronJob.from_dict(
{
"id": "j1",
"name": "t",
"state": {"run_history": [None]},
}
)
def test_load_jobs_coerces_string_schedule_and_state_ms(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
+19
View File
@@ -1,3 +1,5 @@
import json
import pytest
from nanobot.pairing import __all__ as pairing_all
@@ -272,6 +274,23 @@ def test_load_treats_null_approved_and_pending_maps_as_empty(tmp_path, monkeypat
assert store.get_approved("telegram") == []
@pytest.mark.parametrize(
("field", "value"),
[("approved", "corrupt"), ("pending", ["corrupt"])],
)
def test_load_treats_non_object_approved_and_pending_maps_as_empty(
tmp_path, monkeypatch, field, value
):
path = tmp_path / "pairing.json"
payload = {"approved": {}, "pending": {}}
payload[field] = value
path.write_text(json.dumps(payload), encoding="utf-8")
monkeypatch.setattr(store, "_store_path", lambda: path)
assert store.is_approved("telegram", "123") is False
assert store.list_pending() == []
@pytest.mark.parametrize("payload", ["null", "[]", "true"])
def test_load_treats_non_object_store_as_empty(tmp_path, monkeypatch, payload):
path = tmp_path / "pairing.json"
+1 -1
View File
@@ -181,7 +181,7 @@ def test_resolver_env_ref_missing_var_degrades_to_not_configured() -> None:
# Unresolved reference degrades to a falsy key rather than the literal
# "${...}" string, so the config reports itself as not configured.
assert not resolved.api_key
assert resolved.api_key == ""
assert resolved.configured is False
+24 -88
View File
@@ -15,7 +15,6 @@ from nanobot.api.server import (
_save_base64_data_url,
create_app,
)
from nanobot.utils.document import extract_documents
try:
from aiohttp.test_utils import TestClient, TestServer
@@ -157,6 +156,28 @@ def test_parse_json_content_validates_user_role() -> None:
_parse_json_content(body)
@pytest.mark.parametrize(
("part", "field"),
[
({"type": "text", "text": 1}, r"content\[\]\.text"),
(
{"type": "image_url", "image_url": "not-an-object"},
r"content\[\]\.image_url",
),
(
{"type": "image_url", "image_url": {"url": 1}},
r"image_url\.url",
),
],
)
def test_parse_json_content_validates_typed_block_fields(part, field) -> None:
"""Dynamic content blocks are checked before their values reach typed code."""
body = {"messages": [{"role": "user", "content": [part]}]}
with pytest.raises(TypeError, match=field):
_parse_json_content(body)
def test_parse_json_content_rejects_oversized_base64_file(tmp_path) -> None:
"""Oversized JSON data URLs should fail before writing to disk."""
large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode()
@@ -383,98 +404,13 @@ async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) ->
# ---------------------------------------------------------------------------
# extract_documents tests (now in nanobot.utils.document)
# ---------------------------------------------------------------------------
def test_extract_documents_separates_images_from_docs(tmp_path) -> None:
"""Images stay in media; document text is appended to content."""
from docx import Document
png = tmp_path / "chart.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
doc = Document()
doc.add_paragraph("Quarterly revenue is $5M")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
text, image_paths = extract_documents("summarize", [str(png), str(docx_path)])
assert len(image_paths) == 1
assert image_paths[0] == str(png)
assert "Quarterly revenue" in text
assert "summarize" in text
def test_extract_documents_skips_extraction_errors(tmp_path, monkeypatch) -> None:
"""Document extraction errors should not leak into user text."""
bad_file = tmp_path / "broken.docx"
bad_file.write_text("not a docx", encoding="utf-8")
import nanobot.utils.document as _doc
monkeypatch.setattr(
_doc, "extract_text",
lambda _path: "[error: failed to extract DOCX: boom]",
)
text, image_paths = extract_documents("hello", [str(bad_file)])
assert text == "hello"
assert image_paths == []
def test_extract_documents_images_only(tmp_path) -> None:
"""When all files are images, text is unchanged and all paths kept."""
png = tmp_path / "a.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
text, image_paths = extract_documents("describe", [str(png)])
assert text == "describe"
assert len(image_paths) == 1
def test_extract_documents_skips_oversized_files(tmp_path) -> None:
"""Files exceeding the size limit should be silently skipped."""
big = tmp_path / "huge.txt"
big.write_bytes(b"x" * 200)
text, image_paths = extract_documents("hello", [str(big)], max_file_size=100)
assert text == "hello"
assert image_paths == []
def test_extract_documents_does_not_read_full_file_for_mime(tmp_path) -> None:
"""MIME detection should only read header bytes, not the entire file."""
from pathlib import Path as _Path
big_txt = tmp_path / "big.txt"
big_txt.write_bytes(b"hello world " * 100_000) # ~1.2 MB
original_read_bytes = _Path.read_bytes
read_sizes: list[int] = []
def _tracking_read_bytes(self):
data = original_read_bytes(self)
read_sizes.append(len(data))
return data
import unittest.mock
with unittest.mock.patch.object(_Path, "read_bytes", _tracking_read_bytes):
extract_documents("test", [str(big_txt)])
# If the full file was read for MIME detection, read_sizes would
# contain a >1MB entry. After the fix, only a small header is read.
assert all(size <= 4096 for size in read_sizes), (
f"extract_documents read full file for MIME detection: sizes={read_sizes}"
)
# ---------------------------------------------------------------------------
# DOCX upload test — API saves file, loop layer extracts text
# DOCX upload test — API saves file for on-demand reading
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_docx_upload_passes_media_path(aiohttp_client, tmp_path) -> None:
"""Uploaded DOCX is saved to disk and its path passed as media.
(Text extraction happens later in AgentLoop._process_message.)"""
"""Uploaded DOCX is saved to disk and its path is passed through unchanged."""
agent = _make_mock_agent("report summary")
import os
original_cwd = os.getcwd()
+7 -81
View File
@@ -1,8 +1,8 @@
"""Tests for context builder media handling.
The ContextBuilder._build_user_content method should ONLY handle images.
Document text extraction is the responsibility of the processing layer
(AgentLoop._process_message and _drain_pending).
The ContextBuilder.build_user_content method should ONLY handle images.
The processing layer turns non-image media into attachment path references;
document contents are read on demand through ``read_file``.
"""
from __future__ import annotations
@@ -10,7 +10,6 @@ from __future__ import annotations
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.utils.document import extract_documents
def _make_builder(tmp_path: Path) -> ContextBuilder:
@@ -20,7 +19,7 @@ def _make_builder(tmp_path: Path) -> ContextBuilder:
def test_build_user_content_with_no_media_returns_string(tmp_path: Path) -> None:
builder = _make_builder(tmp_path)
result = builder._build_user_content("hello", None)
result = builder.build_user_content("hello", None)
assert result == "hello"
@@ -29,7 +28,7 @@ def test_build_user_content_with_image_returns_list(tmp_path: Path) -> None:
builder = _make_builder(tmp_path)
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
result = builder._build_user_content("describe this", [str(png)])
result = builder.build_user_content("describe this", [str(png)])
assert isinstance(result, list)
types = [b["type"] for b in result]
assert "image_url" in types
@@ -41,7 +40,7 @@ def test_build_user_content_ignores_non_image_files(tmp_path: Path) -> None:
builder = _make_builder(tmp_path)
txt = tmp_path / "notes.txt"
txt.write_text("some text", encoding="utf-8")
result = builder._build_user_content("summarize", [str(txt)])
result = builder.build_user_content("summarize", [str(txt)])
assert result == "summarize"
@@ -53,81 +52,8 @@ def test_build_user_content_mixed_image_and_non_image(tmp_path: Path) -> None:
txt = tmp_path / "report.txt"
txt.write_text("report text", encoding="utf-8")
result = builder._build_user_content("analyze", [str(png), str(txt)])
result = builder.build_user_content("analyze", [str(png), str(txt)])
assert isinstance(result, list)
assert any(b["type"] == "image_url" for b in result)
text_parts = [b.get("text", "") for b in result if b.get("type") == "text"]
assert all("report text" not in t for t in text_parts)
# ---------------------------------------------------------------------------
# Bug detection: extract_documents must be called BEFORE _build_user_content
# to prevent document media from being silently dropped.
# This simulates the _drain_pending code path.
# ---------------------------------------------------------------------------
def test_drain_pending_path_preserves_document_text(tmp_path: Path) -> None:
"""Simulates the _drain_pending path: a pending follow-up message
with a document attachment must have its text extracted before being
passed to _build_user_content. Without extract_documents, the
document is silently dropped."""
from docx import Document
doc = Document()
doc.add_paragraph("Quarterly revenue is $5M")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
content = "summarize"
media = [str(docx_path)]
# Step 1: extract_documents separates docs from images
new_content, image_only = extract_documents(content, media)
# Step 2: _build_user_content handles only images (none left here)
builder = _make_builder(tmp_path)
result = builder._build_user_content(new_content, image_only if image_only else None)
# The document text should be present in the final content
assert "Quarterly revenue" in result
assert "summarize" in result
def test_drain_pending_path_preserves_docx_table_text(tmp_path: Path) -> None:
"""Uploaded Word forms must retain content stored in table cells."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Applicant"
table.cell(0, 1).text = "Ada Lovelace"
table.cell(1, 0).text = "Research area"
table.cell(1, 1).text = "Analytical engines"
docx_path = tmp_path / "application.docx"
doc.save(docx_path)
content, image_only = extract_documents("summarize", [str(docx_path)])
assert image_only == []
assert "Applicant\tAda Lovelace" in content
assert "Research area\tAnalytical engines" in content
def test_drain_pending_path_without_extract_loses_document(tmp_path: Path) -> None:
"""Demonstrates the BUG: if _drain_pending calls _build_user_content
directly without extract_documents, document content is lost."""
from docx import Document
doc = Document()
doc.add_paragraph("Secret data in document")
docx_path = tmp_path / "report.docx"
doc.save(docx_path)
builder = _make_builder(tmp_path)
# Bug path: call _build_user_content directly with document media
result = builder._build_user_content("summarize", [str(docx_path)])
# The document text is LOST — _build_user_content ignores non-images
assert result == "summarize" # only the original text, no doc content
assert "Secret data" not in result
+7
View File
@@ -67,6 +67,13 @@ class TestExtractText:
result = extract_text(txt_file)
assert result == content
def test_extract_text_accepts_string_path(self, tmp_path: Path):
"""String paths retain the compatibility behavior of Path inputs."""
txt_file = tmp_path / "string-path.txt"
txt_file.write_text("string path", encoding="utf-8")
assert extract_text(str(txt_file)) == "string path"
def test_extract_text_txt_file_with_truncation(self, tmp_path: Path):
"""Test that large text files are truncated."""
txt_file = tmp_path / "large.txt"
+225
View File
@@ -85,6 +85,26 @@ def test_from_config_missing_file():
Nanobot.from_config("/nonexistent/config.json")
def test_from_config_missing_env_reports_explicit_config_path(
tmp_path,
monkeypatch,
) -> None:
from nanobot.config.errors import ConfigLoadError
name = "NANOBOT_TEST_SDK_MISSING_KEY"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "custom.json"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
Nanobot.from_config(config_path)
assert exc_info.value.path == config_path.resolve()
def test_from_config_creates_instance(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
@@ -341,10 +361,213 @@ async def test_run_custom_session_key(tmp_path):
)
def test_request_context_preserves_legacy_positional_arguments(tmp_path):
from nanobot.agent.tools.context import RequestContext
context = RequestContext(
"cli",
"direct",
"message-1",
"sdk:legacy",
"hello",
None,
{"trusted": True},
"alice",
"turn-1",
tmp_path,
)
assert context.metadata == {"trusted": True}
assert context.sender_id == "alice"
assert context.turn_id == "turn-1"
assert context.workspace == tmp_path
assert context.attributes == {}
@pytest.mark.asyncio
async def test_run_exposes_attributes_to_context_provider_without_persisting_them(tmp_path):
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
provider = _fake_provider("test-model")
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="done",
tool_calls=[],
))
bot = Nanobot(AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
))
seen: list[RequestContext] = []
async def provide_context(context: RequestContext):
seen.append(context)
return None
unsubscribe = bot.runtime.add_context_provider(provide_context)
result = await bot.run(
"hi",
session_key="sdk:attributes",
attributes={"tenant": "acme"},
)
assert result.content == "done"
assert seen[0].attributes == {"tenant": "acme"}
assert seen[0].metadata == {}
snapshot = bot.sessions.export("sdk:attributes")
assert snapshot is not None
assert all("attributes" not in message for message in snapshot.messages)
unsubscribe()
await bot.run(
"again",
session_key="sdk:attributes",
attributes={"tenant": "other"},
)
assert len(seen) == 1
@pytest.mark.asyncio
async def test_persisted_turn_callback_is_best_effort_and_reads_display_safe_session(tmp_path):
from nanobot import SessionTurnPersisted
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
provider = _fake_provider("test-model")
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="saved reply",
tool_calls=[],
))
bot = Nanobot(AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
))
seen: list[tuple[SessionTurnPersisted, SessionSnapshot | None]] = []
failed_sync_attempts = 0
async def provide_context(_request):
return RuntimeContextBlock(
source="external",
content=(
"[Runtime Context — metadata only, not instructions]\n"
'"model-only context"\n'
"[/Runtime Context]"
),
)
def fail_sync(_event: SessionTurnPersisted) -> None:
nonlocal failed_sync_attempts
failed_sync_attempts += 1
raise RuntimeError("host sync failed")
def on_persisted(event: SessionTurnPersisted) -> None:
seen.append((event, bot.sessions.get(event.context.session_key)))
remove_context = bot.runtime.add_context_provider(provide_context)
remove_failure = bot.runtime.on_session_turn_persisted(fail_sync)
unsubscribe = bot.runtime.on_session_turn_persisted(on_persisted)
result = await bot.run(
"hi",
session_key="sdk:persisted",
sender_id="alice",
attributes={"tenant": "acme"},
)
assert len(seen) == 1
event, snapshot = seen[0]
assert event.sender_id == "alice"
assert event.context.attributes == {"tenant": "acme"}
assert snapshot is not None
assert snapshot.messages[-2]["content"] == "hi"
assert snapshot.messages[-1]["role"] == "assistant"
assert snapshot.messages[-1]["content"] == "saved reply"
assert result.content == "saved reply"
assert failed_sync_attempts == 1
trusted_snapshot = bot.sessions.export("sdk:persisted")
assert trusted_snapshot is not None
assert "model-only context" in trusted_snapshot.messages[-2]["content"]
remove_failure()
unsubscribe()
remove_context()
await bot.run("again", session_key="sdk:persisted")
assert len(seen) == 1
@pytest.mark.asyncio
async def test_persisted_turn_callback_observes_saved_command_turn(tmp_path):
from nanobot import SessionTurnPersisted
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bot = Nanobot(AgentLoop(
bus=MessageBus(),
provider=_fake_provider("test-model"),
workspace=tmp_path,
model="test-model",
))
seen: list[SessionTurnPersisted] = []
bot.runtime.on_session_turn_persisted(seen.append)
await bot.run("/skill", session_key="sdk:command")
assert len(seen) == 1
snapshot = bot.sessions.export("sdk:command")
assert snapshot is not None
assert [message["role"] for message in snapshot.messages[-2:]] == [
"user",
"assistant",
]
@pytest.mark.asyncio
async def test_ephemeral_run_does_not_invoke_persisted_turn_callback(tmp_path):
from nanobot import SessionTurnPersisted
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
provider = _fake_provider("test-model")
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="temporary",
tool_calls=[],
))
bot = Nanobot(AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
))
seen: list[SessionTurnPersisted] = []
bot.runtime.on_session_turn_persisted(seen.append)
await bot.run("hi", session_key="sdk:ephemeral", ephemeral=True)
assert seen == []
def test_runtime_client_does_not_expose_generic_event_subscription():
from nanobot.sdk.clients import RuntimeClient
assert hasattr(RuntimeClient, "on_session_turn_persisted")
assert not hasattr(RuntimeClient, "subscribe")
def test_import_from_top_level():
import nanobot
assert nanobot.Nanobot is Nanobot
assert nanobot.RequestContext.__name__ == "RequestContext"
assert nanobot.RuntimeContextBlock.__name__ == "RuntimeContextBlock"
assert nanobot.RuntimeContextProvider is not None
assert nanobot.SessionTurnPersisted.__name__ == "SessionTurnPersisted"
assert nanobot.RunResult is RunResult
assert nanobot.RunStream is RunStream
assert nanobot.SessionInfo is SessionInfo
@@ -997,6 +1220,7 @@ async def test_run_streamed_forwards_runtime_options(tmp_path):
sender_id="alice",
media=["/tmp/image.png"],
ephemeral=True,
attributes={"tenant": "acme"},
)
await run.wait()
@@ -1009,6 +1233,7 @@ async def test_run_streamed_forwards_runtime_options(tmp_path):
assert kwargs["sender_id"] == "alice"
assert kwargs["media"] == ["/tmp/image.png"]
assert kwargs["ephemeral"] is True
assert kwargs["attributes"] == {"tenant": "acme"}
assert callable(kwargs["on_stream"])
assert callable(kwargs["on_stream_end"])
assert kwargs["hooks"]
+20 -3
View File
@@ -230,8 +230,8 @@ class TestSpawnWindows:
assert "if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }" in command
@pytest.mark.asyncio
async def test_powershell_configures_utf8_output(self):
"""PowerShell should emit UTF-8 for captured output and redirections."""
async def test_powershell_configures_utf8_io(self):
"""PowerShell should use UTF-8 for captured output, native input, and redirections."""
env = {"PATH": ""}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
@@ -242,7 +242,10 @@ class TestSpawnWindows:
command = mock_exec.call_args[0][-1]
assert "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)" in command
assert "$OutputEncoding =" not in command
assert (
"if ($PSVersionTable.PSVersion.Major -lt 6) { "
"$OutputEncoding = [Console]::OutputEncoding }"
) in command
assert "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'" in command
@pytest.mark.asyncio
@@ -821,6 +824,20 @@ class TestWindowsRealExec:
assert b"\x00" not in data
assert data.decode("utf-8-sig").strip() == "file café λ 你好"
@pytest.mark.asyncio
async def test_windows_powershell_native_pipeline_input_is_utf8(self):
python = sys.executable.replace("'", "''")
result = await ExecTool(timeout=180).execute(
command=(
f"[string][char]0x4F1A | & '{python}' "
'-c "import sys; print(sys.stdin.buffer.read().hex())"'
),
shell="powershell",
)
assert "e4bc9a0d0a" in result
assert "Exit code: 0" in result
@pytest.mark.asyncio
async def test_windows_powershell_session_output_is_utf8(self):
manager = ExecSessionManager()
+16 -7
View File
@@ -334,27 +334,36 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path):
def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
async def run() -> tuple[str, str, str]:
async def run() -> tuple[str, str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _waiting_shell_command("booting")
command = _waiting_shell_command("booting", delayed="ready")
initial = await exec_tool.execute(command=command, yield_time_ms=100)
initial = await exec_tool.execute(command=command, yield_time_ms=0)
sid = _session_id(initial)
# Synchronize on an stdin-gated marker before exercising the immediate timeout below.
ready = await stdin_tool.execute(
session_id=sid,
chars="\n",
wait_for="ready",
wait_timeout_ms=10000,
yield_time_ms=0,
)
waited = await stdin_tool.execute(
session_id=sid,
wait_for="never-ready",
wait_timeout_ms=200,
wait_timeout_ms=0,
yield_time_ms=0,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, waited, cleanup
return initial, ready, waited, cleanup
initial, waited, cleanup = asyncio.run(run())
initial, ready, waited, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "booting" in initial + waited
assert "booting" in initial + ready
assert "ready" in ready
assert "Process running" in waited
assert "Wait target not observed: 'never-ready'" in waited
assert "Session terminated." in cleanup
+8
View File
@@ -26,6 +26,14 @@ from nanobot.config.schema import MCPServerConfig
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
def test_type_checking_only_mcp_annotations_are_deferred() -> None:
assert mcp_mod._MCPWrapperBase.__annotations__["_session"] == "ClientSession"
assert MCPToolWrapper.__init__.__annotations__["session"] == "ClientSession"
assert MCPResourceWrapper.__init__.__annotations__["resource_def"] == "Resource"
assert MCPPromptWrapper.__init__.__annotations__["prompt_def"] == "Prompt"
assert connect_mcp_servers.__annotations__["mcp_servers"] == "dict[str, MCPServerConfig]"
class _FakeTextContent:
def __init__(self, text: str) -> None:
self.text = text
+10
View File
@@ -96,6 +96,16 @@ class TestReadDedup:
# Images should always return full content blocks, not a stub
assert isinstance(second, list)
@pytest.mark.asyncio
async def test_known_text_extension_falls_back_to_latin1(self, tool, tmp_path):
f = tmp_path / "legacy.csv"
f.write_bytes("name\ncafé".encode("latin-1"))
result = await tool.execute(path=str(f))
assert "1| name" in result
assert "2| café" in result
# ---------------------------------------------------------------------------
# Cross-session isolation (issue #3571)
+2
View File
@@ -36,6 +36,8 @@ def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None:
assert "find_files/list_dir first" in read_file
assert "before editing" in read_file
assert "uploaded non-image attachments are referenced by path" in read_file
assert "only when their contents are needed" in read_file
assert "prefer it over shell find/ls" in find_files
assert "prefer this over shell grep" in grep
+28
View File
@@ -7,6 +7,7 @@ import tiktoken
from nanobot.utils import helpers
from nanobot.utils.helpers import (
_write_text_atomic,
content_with_media_breadcrumbs,
current_time_str,
split_message,
truncate_text_to_tokens,
@@ -55,6 +56,33 @@ def test_current_time_str_rejects_unknown_timezone():
current_time_str("Not/AZone")
def test_content_with_media_breadcrumbs_preserves_valid_paths():
assert content_with_media_breadcrumbs(
"user",
"review these",
["/media/report.pdf", "/media/clip.mp4"],
) == (
"review these\n"
"[image: /media/report.pdf]\n"
"[image: /media/clip.mp4]"
)
def test_content_with_media_breadcrumbs_only_rewrites_plain_user_content():
structured = [{"type": "text", "text": "hello"}]
assert content_with_media_breadcrumbs(
"assistant",
"done",
["/media/output.png"],
) == "done"
assert content_with_media_breadcrumbs(
"user",
structured,
["/media/input.png"],
) is structured
def test_write_text_atomic_fsyncs_file_and_parent_directory(
tmp_path: Path, monkeypatch
) -> None:
+139
View File
@@ -389,6 +389,7 @@ def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
assert out is not None
assert out["has_pending_tool_calls"] is False
assert out["completed_turn_ids"] == [turn_id]
assert out["messages"][-1]["kind"] == "trace"
assert out["messages"][-2]["content"] == "Cron test"
@@ -410,6 +411,144 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
assert out is not None
assert out["has_pending_tool_calls"] is True
assert out["completed_turn_ids"] == []
def test_thread_response_reports_active_registry_without_transcript(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
out = build_webui_thread_response(
"websocket:active-without-transcript",
active_turn_started_at=1_700_000_000.0,
active_turn_id="turn-active",
)
assert out is not None
assert out["messages"] == []
assert out["completed_turn_ids"] == []
assert out["has_pending_tool_calls"] is True
assert out["active_turn_id"] == "turn-active"
def test_thread_response_reports_explicit_completion_without_assistant_row(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:empty-answer"
turn_id = "turn-empty-answer"
append_transcript_object(
key,
{"event": "user", "chat_id": "empty-answer", "text": "stop", "turn_id": turn_id},
)
append_transcript_object(
key,
{"event": "turn_end", "chat_id": "empty-answer", "turn_id": turn_id},
)
out = build_webui_thread_response(key)
assert out is not None
assert out["messages"][-1]["role"] == "user"
assert out["has_pending_tool_calls"] is False
assert out["completed_turn_ids"] == [turn_id]
def test_incomplete_turn_with_ambiguous_session_match_stays_pending(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:ambiguous-incomplete"
turn_id = "turn-ambiguous"
append_transcript_object(
key,
{
"event": "user",
"chat_id": "ambiguous-incomplete",
"text": "repeat",
"turn_id": turn_id,
},
)
append_transcript_object(
key,
{
"event": "turn_end",
"chat_id": "ambiguous-incomplete",
"turn_id": turn_id,
"transcript_incomplete": True,
},
)
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "repeat"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "repeat"},
{"role": "assistant", "content": "second answer"},
],
)
assert out is not None
assert [(message["role"], message["content"]) for message in out["messages"]] == [
("user", "repeat"),
]
assert out["completed_turn_ids"] == []
assert out["has_pending_tool_calls"] is True
def test_later_completion_does_not_hide_older_incomplete_turn(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:older-incomplete"
for event in (
{"event": "user", "text": "first", "turn_id": "turn-first"},
{
"event": "turn_end",
"turn_id": "turn-first",
"transcript_incomplete": True,
},
{"event": "user", "text": "second", "turn_id": "turn-second"},
{"event": "message", "text": "second answer", "turn_id": "turn-second"},
{"event": "turn_end", "turn_id": "turn-second"},
):
append_transcript_object(
key,
{"chat_id": "older-incomplete", **event},
)
out = build_webui_thread_response(key)
assert out is not None
assert out["completed_turn_ids"] == ["turn-second"]
assert out["has_pending_tool_calls"] is True
def test_active_registry_does_not_hide_a_newer_queued_turn(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:queued-tail"
for event in (
{"event": "user", "text": "first", "turn_id": "turn-old"},
{"event": "message", "text": "done", "turn_id": "turn-old"},
{"event": "turn_end", "turn_id": "turn-old"},
{"event": "user", "text": "queued next", "turn_id": "turn-new"},
):
append_transcript_object(key, {"chat_id": "queued-tail", **event})
out = build_webui_thread_response(
key,
active_turn_started_at=1_700_000_000.0,
active_turn_id="turn-old",
)
assert out is not None
assert out["has_pending_tool_calls"] is True
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
+69 -3
View File
@@ -8,26 +8,40 @@ from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
from nanobot.session import webui_turns as wth
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
@pytest.fixture(autouse=True)
def _clear_turn_wall_clock() -> None:
wth._WEBSOCKET_ACTIVE_TURNS.clear()
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
wth._WEBSOCKET_TURN_IDS.clear()
wth._WEBSOCKET_TURN_OWNERS.clear()
yield
wth._WEBSOCKET_ACTIVE_TURNS.clear()
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
wth._WEBSOCKET_TURN_IDS.clear()
wth._WEBSOCKET_TURN_OWNERS.clear()
@pytest.mark.asyncio
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat-a",
content="hi",
metadata={"webui_turn_id": "turn-a"},
)
await wth.publish_turn_run_status(bus, msg, "running")
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
t0 = wth.websocket_turn_wall_started_at("chat-a")
assert isinstance(t0, float)
assert wth.websocket_turn_id("chat-a") == "turn-a"
call = bus.publish_outbound.await_args[0][0]
assert call.chat_id == "chat-a"
assert isinstance(call.event, GoalStatusEvent)
@@ -49,16 +63,67 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
@pytest.mark.asyncio
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
async def test_publish_turn_run_status_idle_retains_registry_until_delivery() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-b", content="hi")
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat-b",
content="hi",
metadata={"webui_turn_id": "turn-b"},
)
await wth.publish_turn_run_status(bus, msg, "running")
assert wth.websocket_turn_wall_started_at("chat-b") is not None
assert wth.websocket_turn_id("chat-b") == "turn-b"
await wth.publish_turn_run_status(bus, msg, "idle")
assert wth.websocket_turn_wall_started_at("chat-b") is not None
assert wth.websocket_turn_id("chat-b") == "turn-b"
def test_clear_websocket_turn_only_clears_matching_owner() -> None:
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-b"] = 1234.5
wth._WEBSOCKET_TURN_IDS["chat-b"] = "turn-new"
wth._WEBSOCKET_TURN_OWNERS["chat-b"] = "owner-new"
assert wth.clear_websocket_turn_if_current("chat-b", "owner-old") is False
assert wth.websocket_turn_wall_started_at("chat-b") == 1234.5
assert wth.websocket_turn_id("chat-b") == "turn-new"
assert wth.clear_websocket_turn_if_current("chat-b", "owner-new") is True
assert wth.websocket_turn_wall_started_at("chat-b") is None
assert wth.websocket_turn_id("chat-b") is None
@pytest.mark.asyncio
async def test_ownerless_turns_receive_distinct_internal_owners() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
first = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat-ownerless",
content="first",
)
second = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat-ownerless",
content="second",
)
await wth.publish_turn_run_status(bus, first, "running")
first_owner = first.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
await wth.publish_turn_run_status(bus, second, "running")
second_owner = second.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
assert first_owner != second_owner
assert wth.clear_websocket_turn_if_current("chat-ownerless", first_owner) is True
assert wth._WEBSOCKET_TURN_OWNERS["chat-ownerless"] == second_owner
assert wth.websocket_turn_wall_started_at("chat-ownerless") is not None
assert wth.clear_websocket_turn_if_current("chat-ownerless", second_owner) is True
@pytest.mark.asyncio
@@ -70,6 +135,7 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
await wth.publish_turn_run_status(bus, msg, "running")
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
assert wth._WEBSOCKET_TURN_IDS == {}
@pytest.mark.asyncio
+179
View File
@@ -0,0 +1,179 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.webui.skills_api import (
SkillManagementError,
delete_webui_skill,
set_webui_skill_enabled,
webui_skill_detail_payload,
webui_skills_payload,
)
def _write_skill(workspace: Path, name: str, *, metadata: str = "") -> Path:
directory = workspace / "skills" / name
directory.mkdir(parents=True)
(directory / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} description.\n{metadata}---\n",
encoding="utf-8",
)
return directory
def _config(*disabled: str) -> SimpleNamespace:
return SimpleNamespace(
agents=SimpleNamespace(
defaults=SimpleNamespace(disabled_skills=list(disabled)),
)
)
def test_disabled_skills_remain_visible_and_loadable(tmp_path: Path) -> None:
_write_skill(tmp_path, "custom-skill")
payload = webui_skills_payload(tmp_path, disabled_skills={"custom-skill"})
skill = next(item for item in payload["skills"] if item["name"] == "custom-skill")
assert skill["enabled"] is False
assert skill["deletable"] is True
detail = webui_skill_detail_payload(
tmp_path,
"custom-skill",
disabled_skills={"custom-skill"},
)
assert detail is not None
assert detail["enabled"] is False
assert "custom-skill description" in detail["raw_markdown"]
def test_skill_detail_exposes_copyable_install_commands(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"custom-skill",
metadata=(
'metadata: {"nanobot":{"requires":{"bins":["demo"]},'
'"install":[{"id":"brew","kind":"brew","formula":"acme/demo",'
'"label":"Install demo"}]}}\n'
),
)
detail = webui_skill_detail_payload(tmp_path, "custom-skill")
assert detail is not None
assert detail["install_options"] == [
{
"id": "brew",
"kind": "brew",
"label": "Install demo",
"command": "brew install acme/demo",
}
]
def test_set_webui_skill_enabled_persists_and_updates_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_write_skill(tmp_path, "custom-skill")
config = _config()
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled: set[str] = set()
action = set_webui_skill_enabled(
tmp_path,
"custom-skill",
enabled=False,
disabled_skills=disabled,
)
assert action == {
"name": "custom-skill",
"enabled": False,
"deleted": False,
}
assert config.agents.defaults.disabled_skills == ["custom-skill"]
assert disabled == {"custom-skill"}
assert saved == [config]
def test_delete_webui_skill_only_deletes_workspace_skills(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill")
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled = {"custom-skill"}
action = delete_webui_skill(
tmp_path,
"custom-skill",
disabled_skills=disabled,
)
assert action["deleted"] is True
assert not directory.exists()
assert disabled == set()
assert config.agents.defaults.disabled_skills == []
assert saved == [config]
with pytest.raises(SkillManagementError) as exc_info:
delete_webui_skill(tmp_path, "cron", disabled_skills=disabled)
assert exc_info.value.status == 403
def test_delete_webui_skill_rejects_symlinked_skills_root(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
directory = outside / "custom-skill"
directory.mkdir(parents=True)
(directory / "SKILL.md").write_text(
"---\nname: custom-skill\n---\n",
encoding="utf-8",
)
try:
(workspace / "skills").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
with pytest.raises(SkillManagementError) as exc_info:
delete_webui_skill(workspace, "custom-skill", disabled_skills=set())
assert exc_info.value.status == 403
assert (outside / "custom-skill" / "SKILL.md").is_file()
def test_delete_webui_skill_restores_directory_when_config_save_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill")
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
def fail_save(_config: object) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save)
disabled = {"custom-skill"}
with pytest.raises(OSError, match="disk full"):
delete_webui_skill(
tmp_path,
"custom-skill",
disabled_skills=disabled,
)
assert directory.is_dir()
assert (directory / "SKILL.md").is_file()
assert config.agents.defaults.disabled_skills == ["custom-skill"]
assert disabled == {"custom-skill"}
+561
View File
@@ -0,0 +1,561 @@
import hashlib
import io
import zipfile
from pathlib import Path
from typing import Any
import httpx
import pytest
from nanobot.webui.skills_marketplace import (
SkillsMarketplaceError,
_valid_skillhub_download_url,
_validated_skillhub_entries,
install_marketplace_skill,
marketplace_skill_trends,
search_marketplace_skills,
trending_marketplace_skills,
)
@pytest.mark.asyncio
async def test_search_marketplace_skills_filters_and_marks_installed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: react-testing\n---\n", encoding="utf-8")
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "React Testing",
"skillId": "react-testing",
"source": "acme/agent-skills",
"installs": 42,
},
{"skillId": "../escape", "source": "acme/agent-skills"},
{"skillId": "valid-name", "source": "not-a-repository"},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.skills_install_supported",
lambda: True,
)
payload = await search_marketplace_skills(
" react testing ",
tmp_path,
provider="skills_sh",
)
assert seen == {
"url": "https://skills.sh/api/search",
"params": {"q": "react testing", "limit": 20},
}
assert payload == {
"query": "react testing",
"provider": "skills_sh",
"install_supported": True,
"skills": [
{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"provider": "skills_sh",
"installs": 42,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": True,
"install_supported": True,
"metric": "installs_total",
}
],
}
@pytest.mark.asyncio
async def test_search_skillhub_skills_normalizes_provider_metadata(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"results": [
{
"slug": "ima-skills",
"name": "ima-skills",
"namespace": {"handle": "tencent-adm"},
"source": "enterprise",
"version": "1.1.8",
"installs": 11831,
"downloads": 142525,
"publisher": {"verified": True},
"labels": {"requires_api_key": "true"},
}
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await search_marketplace_skills(
" ima ",
tmp_path,
provider="skillhub",
)
assert seen == {
"url": "https://api.skillhub.cn/api/v1/search",
"params": {"q": "ima", "limit": 20},
}
assert payload["provider"] == "skillhub"
assert payload["skills"] == [
{
"id": "skillhub:ima-skills",
"skill_id": "ima-skills",
"name": "ima-skills",
"source": "@tencent-adm/ima-skills",
"provider": "skillhub",
"installs": 11831,
"downloads": 142525,
"url": "https://skillhub.cn/tencent-adm/ima-skills",
"installed": False,
"install_supported": True,
"metric": "installs_total",
"version": "1.1.8",
"verified": True,
"requires_api_key": True,
}
]
@pytest.mark.asyncio
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "First",
"skillId": "first",
"source": "acme/skills",
"installs": 50,
},
{
"name": "Second from same source",
"skillId": "second",
"source": "acme/skills",
"installs": 49,
},
{
"name": "Another",
"skillId": "another",
"source": "other/skills",
"installs": 30,
},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://skills.sh/api/skills/trending/0"
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await trending_marketplace_skills(tmp_path, provider="skills_sh")
assert payload["period"] == "24h"
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
("First", 1),
("Another", 3),
]
@pytest.mark.asyncio
async def test_marketplace_skill_trends_returns_history_separately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
text = r"<script>\"values\":[3,5,8,13]</script>"
def raise_for_status(self) -> None:
pass
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://www.skills.sh/other/skills/second"
return FakeResponse()
async def weekly_installs(_client: object) -> dict[tuple[str, str], list[int]]:
return {
("acme/skills", "first"): [2, 4, 3, 8],
}
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace._load_weekly_installs",
weekly_installs,
)
assert await marketplace_skill_trends(
[
"acme/skills/first",
"other/skills/second",
"invalid",
]
) == {
"trends": {
"acme/skills/first": [2, 4, 3, 8],
"other/skills/second": [3, 5, 8, 13],
}
}
@pytest.mark.asyncio
async def test_search_marketplace_skills_returns_safe_upstream_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FailingClient:
async def __aenter__(self) -> "FailingClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, *_args: object, **_kwargs: object) -> None:
raise httpx.ConnectError("private network detail")
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FailingClient(),
)
with pytest.raises(SkillsMarketplaceError) as exc_info:
await search_marketplace_skills("react", tmp_path, provider="skills_sh")
assert exc_info.value.status == 502
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
@pytest.mark.asyncio
async def test_install_marketplace_skill_uses_official_cli_and_workspace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, None]:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\n---\n",
encoding="utf-8",
)
return b"installed", None
def kill(self) -> None:
raise AssertionError("successful install must not be killed")
async def create_subprocess_exec(*command: str, **kwargs: object) -> FakeProcess:
seen.update(command=command, **kwargs)
return FakeProcess()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.shutil.which",
lambda executable: "/usr/local/bin/npx" if executable == "npx" else None,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
result = await install_marketplace_skill(
"acme/agent-skills",
"react-testing",
tmp_path,
)
assert result == {
"installed": True,
"already_installed": False,
"name": "react-testing",
}
assert seen["command"] == (
"/usr/local/bin/npx",
"--yes",
"skills@latest",
"add",
"acme/agent-skills",
"--skill",
"react-testing",
"--agent",
"openclaw",
"--copy",
"--yes",
)
assert seen["cwd"] == str(tmp_path.resolve())
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
@pytest.mark.asyncio
async def test_install_skillhub_skill_checks_fingerprint_and_extracts_safely(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
archive_buffer = io.BytesIO()
skill_content = b"---\nname: ima-skills\ndescription: Tencent knowledge skill.\n---\n"
with zipfile.ZipFile(archive_buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("SKILL.md", skill_content)
archive.writestr("_meta.json", b'{"version":"1.1.8"}')
archive_bytes = archive_buffer.getvalue()
file_hash = hashlib.sha256(skill_content).hexdigest()
content_hash = hashlib.sha256(f"SKILL.md:{file_hash}\n".encode()).hexdigest()
class FakeResponse:
def __init__(
self,
*,
payload: dict[str, Any] | None = None,
status_code: int = 200,
headers: dict[str, str] | None = None,
content: bytes = b"",
) -> None:
self.payload = payload or {}
self.status_code = status_code
self.headers = headers or {}
self.content = content
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise httpx.HTTPStatusError(
"failed",
request=httpx.Request("GET", "https://example.com"),
response=httpx.Response(self.status_code),
)
def json(self) -> dict[str, Any]:
return self.payload
async def __aenter__(self) -> "FakeResponse":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def aiter_bytes(self):
yield self.content[:12]
yield self.content[12:]
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(
self,
url: str,
*,
params: dict[str, str] | None = None,
) -> FakeResponse:
if url.endswith("/signature"):
return FakeResponse(payload={"signed": True, "content_hash": content_hash})
assert url == "https://api.skillhub.cn/api/v1/download"
assert params == {"slug": "ima-skills", "version": "1.1.8"}
return FakeResponse(
status_code=302,
headers={
"location": (
"https://skillhub-1388575217.cos.accelerate.myqcloud.com/"
"skills/ima-skills.zip"
)
},
)
def stream(
self,
method: str,
url: str,
*,
headers: dict[str, str],
) -> FakeResponse:
assert method == "GET"
assert url.endswith("/skills/ima-skills.zip")
assert "application/zip" in headers["Accept"]
return FakeResponse(
headers={"content-length": str(len(archive_bytes))},
content=archive_bytes,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
result = await install_marketplace_skill(
"",
"ima-skills",
tmp_path,
provider="skillhub",
version="1.1.8",
)
assert result == {
"installed": True,
"already_installed": False,
"name": "ima-skills",
"provider": "skillhub",
"version": "1.1.8",
}
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content
@pytest.mark.parametrize(
("url", "valid"),
[
("https://skillhub.cos.myqcloud.com/skills/example.zip", True),
("https://skillhub.cos.myqcloud.com:443/skills/example.zip", True),
("http://skillhub.cos.myqcloud.com/skills/example.zip", False),
("https://myqcloud.com/skills/example.zip", False),
("https://skillhub.cos.myqcloud.com.evil.example/skill.zip", False),
("https://user@skillhub.cos.myqcloud.com/skill.zip", False),
("https://skillhub.cos.myqcloud.com:not-a-port/skill.zip", False),
],
)
def test_skillhub_download_url_allows_only_pinned_cloud_hosts(
url: str,
valid: bool,
) -> None:
assert _valid_skillhub_download_url(url) is valid
def test_skillhub_archive_rejects_path_traversal() -> None:
archive_buffer = io.BytesIO()
with zipfile.ZipFile(archive_buffer, "w") as archive:
archive.writestr("SKILL.md", "---\nname: safe\n---\n")
archive.writestr("../outside.sh", "#!/bin/sh\n")
archive_buffer.seek(0)
with zipfile.ZipFile(archive_buffer) as archive:
with pytest.raises(SkillsMarketplaceError, match="unsafe path"):
_validated_skillhub_entries(archive)
@pytest.mark.asyncio
async def test_install_marketplace_skill_is_idempotent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "already-here"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: already-here\n---\n", encoding="utf-8")
launch = pytest.fail
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
launch,
)
result = await install_marketplace_skill("acme/agent-skills", "already-here", tmp_path)
assert result == {
"installed": True,
"already_installed": True,
"name": "already-here",
}
@pytest.mark.asyncio
async def test_install_marketplace_skill_rejects_symlinked_skills_root(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
try:
(workspace / "skills").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
with pytest.raises(SkillsMarketplaceError) as exc_info:
await install_marketplace_skill(
"",
"ima-skills",
workspace,
provider="skillhub",
version="1.1.8",
)
assert exc_info.value.status == 403
assert list(outside.iterdir()) == []