mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Merge origin/main into feat/resource-links
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 |) -----------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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()) == []
|
||||
Reference in New Issue
Block a user