Merge origin/main into feat/resource-links

This commit is contained in:
chengyongru
2026-07-30 10:18:05 +08:00
269 changed files with 20077 additions and 3922 deletions
+208
View File
@@ -0,0 +1,208 @@
import asyncio
import base64
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
from nanobot.providers.base import LLMResponse
from nanobot.utils.document import reference_non_image_attachments
def _make_loop(
workspace: Path,
channels_config: ChannelsConfig | None = None,
) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok"))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=workspace,
model="test-model",
channels_config=channels_config,
)
def _turn_context(loop: AgentLoop, msg: InboundMessage) -> TurnContext:
return TurnContext(
msg=msg,
session_key=f"{msg.channel}:{msg.chat_id}",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, f"{msg.channel}:{msg.chat_id}"),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("extract_document_text", [True, False])
async def test_document_attachment_is_referenced_and_read_on_demand(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
extract_document_text: bool,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
media_dir = tmp_path / "media"
media_dir.mkdir()
csv_path = media_dir / "report.csv"
csv_path.write_text("name,value\nnanobot,1", encoding="utf-8")
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media_dir)
loop = _make_loop(
workspace,
ChannelsConfig(extract_document_text=extract_document_text),
)
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="c",
content="import this report",
media=[str(csv_path)],
)
ctx = _turn_context(loop, msg)
await loop._restore_turn(ctx)
assert ctx.msg.content == f"import this report\n\n[Attachment: {csv_path}]"
assert "name,value" not in ctx.msg.content
assert ctx.msg.media == []
read_tool = ReadFileTool(workspace=workspace, allowed_dir=workspace)
result = await read_tool.execute(path=str(csv_path))
assert "1| name,value" in result
assert "2| nanobot,1" in result
@pytest.mark.asyncio
async def test_document_reference_survives_session_reload(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
doc_path = tmp_path / "report.csv"
doc_path.write_text("name,value", encoding="utf-8")
loop = _make_loop(workspace)
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("interrupt")) # type: ignore[method-assign]
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="persisted-attachment",
content="review this",
media=[str(doc_path)],
)
with pytest.raises(RuntimeError, match="interrupt"):
await loop._process_message(msg)
session_key = "websocket:persisted-attachment"
loop.sessions.invalidate(session_key)
persisted = loop.sessions.get_or_create(session_key)
assert [message["role"] for message in persisted.messages] == ["user"]
assert persisted.messages[0]["content"] == (
f"review this\n\n[Attachment: {doc_path.resolve()}]"
)
assert "media" not in persisted.messages[0]
@pytest.mark.asyncio
async def test_pending_document_attachment_keeps_body_out_of_prompt(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
doc_path = tmp_path / "followup.txt"
doc_path.write_text("Do not inject this file body", encoding="utf-8")
captured_messages: list[list[dict]] = []
call_count = 0
async def chat_with_retry(*, messages: list[dict], **kwargs: object) -> LLMResponse:
nonlocal call_count
call_count += 1
captured_messages.append([dict(message) for message in messages])
return LLMResponse(content=f"answer-{call_count}", tool_calls=[], usage={})
loop = _make_loop(workspace)
loop.provider.chat_with_retry = chat_with_retry
loop.tools.get_definitions = MagicMock(return_value=[])
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
await pending_queue.put(
InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="check this",
media=[str(doc_path)],
)
)
final_content, _, _, _, had_injections = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
pending_queue=pending_queue,
)
assert final_content == "answer-2"
assert had_injections is True
injected_user_content = [
message["content"]
for message in captured_messages[-1]
if message.get("role") == "user" and isinstance(message.get("content"), str)
][-1]
assert "check this" in injected_user_content
assert f"[Attachment: {doc_path}]" in injected_user_content
assert "Do not inject this file body" not in injected_user_content
def test_attachment_references_still_preserve_images(tmp_path: Path) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.txt"
doc_path.write_text("manual extraction target", encoding="utf-8")
content, media = reference_non_image_attachments(
"review these",
[str(image_path), str(doc_path)],
)
assert media == [str(image_path)]
assert f"[Attachment: {doc_path}]" in content
assert "manual extraction target" not in content
def test_attachment_references_canonicalize_existing_relative_paths(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.csv"
doc_path.write_text("name,value", encoding="utf-8")
monkeypatch.chdir(tmp_path)
content, media = reference_non_image_attachments(
"review these",
[image_path.name, doc_path.name],
)
assert media == [str(image_path.resolve())]
assert f"[Attachment: {doc_path.resolve()}]" in content
+35
View File
@@ -75,6 +75,41 @@ def _tool_round(call_id: str) -> list[dict]:
class TestConsolidatorSummarize:
async def test_archive_prompt_includes_media_breadcrumb(
self, consolidator, mock_provider, store, runtime
):
path = "/home/user/.nanobot/media/websocket/upload_photo.png"
summary = "User uploaded a photo."
mock_provider.chat_with_retry.return_value = MagicMock(
content=summary,
finish_reason="stop",
)
result = await consolidator.archive(
[{"role": "user", "content": "please inspect this", "media": [path]}],
runtime=runtime,
)
prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
entries = store.read_unprocessed_history(since_cursor=0)
assert f"[image: {path}]" in prompt
assert result == summary
assert [entry["content"] for entry in entries] == [summary]
def test_format_messages_keeps_media_only_user_turn(self):
path = "/home/user/.nanobot/media/websocket/clip.mp4"
formatted = MemoryStore._format_messages([
{
"role": "user",
"content": "",
"media": [path],
"timestamp": "2026-07-27",
}
])
assert formatted == f"[2026-07-27] USER: [image: {path}]"
async def test_archive_excludes_model_only_runtime_context(
self, consolidator, mock_provider, runtime
):
+36 -7
View File
@@ -245,38 +245,38 @@ class TestBundledToolContract:
# ---------------------------------------------------------------------------
# _build_user_content
# build_user_content
# ---------------------------------------------------------------------------
class TestBuildUserContent:
def test_no_media_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", None)
result = builder.build_user_content("hello", None)
assert result == "hello"
def test_empty_media_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [])
result = builder.build_user_content("hello", [])
assert result == "hello"
def test_nonexistent_media_file_returns_string(self, tmp_path):
builder = _builder(tmp_path)
result = builder._build_user_content("hello", ["/nonexistent/image.png"])
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
assert result == "hello"
def test_non_image_file_returns_string(self, tmp_path):
txt = tmp_path / "doc.txt"
txt.write_text("not an image", encoding="utf-8")
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(txt)])
result = builder.build_user_content("hello", [str(txt)])
assert result == "hello"
def test_valid_image_returns_list(self, tmp_path):
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(png)])
result = builder.build_user_content("hello", [str(png)])
assert isinstance(result, list)
assert len(result) == 2
assert result[0]["type"] == "image_url"
@@ -288,7 +288,7 @@ class TestBuildUserContent:
png = tmp_path / "test.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
result = builder._build_user_content("hello", [str(png)])
result = builder.build_user_content("hello", [str(png)])
assert "_meta" in result[0]
assert "path" in result[0]["_meta"]
@@ -467,6 +467,35 @@ class TestBuildMessages:
assert "user-only runtime context" not in messages[-1]["content"]
assert "_meta" not in messages[-1]
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
skill_dir = tmp_path / "skills" / "review"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: review\n"
"description: Review changes.\n"
"---\n\n"
"# Review workflow\n\nFollow the unique review checklist.",
encoding="utf-8",
)
builder = _builder(tmp_path)
messages = builder.build_messages([], "Please $review this patch and use $review carefully.")
system_prompt = messages[0]["content"]
assert "# Active Skills" in system_prompt
assert "### Skill: review" in system_prompt
assert "Follow the unique review checklist." in system_prompt
assert system_prompt.count("### Skill: review") == 1
assert messages[-1]["content"] == (
"Please $review this patch and use $review carefully."
)
def test_unknown_skill_reference_does_not_change_active_skills(self, tmp_path):
messages = _builder(tmp_path).build_messages([], "Keep the shell literal $HOME.")
assert "# Active Skills" not in messages[0]["content"]
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages([], "hello", channel="cli")
@@ -1,176 +0,0 @@
import asyncio
import base64
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
from nanobot.providers.base import LLMResponse
from nanobot.utils.document import reference_non_image_attachments
def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok"))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
channels_config=channels_config,
)
@pytest.mark.asyncio
async def test_restore_turn_extracts_documents_by_default(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
loop = _make_loop(tmp_path)
doc_path = tmp_path / "report.txt"
doc_path.write_text("Quarterly revenue is $5M", encoding="utf-8")
calls: list[tuple[str, list[str]]] = []
def fake_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
calls.append((content, media))
return f"{content}\n\n[File: report.txt]\nQuarterly revenue is $5M", []
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fake_extract_documents)
msg = InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="summarize",
media=[str(doc_path)],
)
ctx = TurnContext(
msg=msg,
session_key="cli:c",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
await loop._restore_turn(ctx)
assert calls == [("summarize", [str(doc_path)])]
assert "Quarterly revenue" in ctx.msg.content
assert ctx.msg.media == []
@pytest.mark.asyncio
async def test_restore_turn_references_documents_when_extraction_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
loop = _make_loop(tmp_path, ChannelsConfig(extract_document_text=False))
doc_path = tmp_path / "report.txt"
doc_path.write_text("Quarterly revenue is $5M", encoding="utf-8")
def fail_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
raise AssertionError("document extraction should be disabled")
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fail_extract_documents)
msg = InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="summarize",
media=[str(doc_path)],
)
ctx = TurnContext(
msg=msg,
session_key="cli:c",
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
await loop._restore_turn(ctx)
assert "Quarterly revenue" not in ctx.msg.content
assert f"[Attachment: {doc_path}]" in ctx.msg.content
assert ctx.msg.media == []
@pytest.mark.asyncio
async def test_pending_followup_references_documents_when_extraction_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
doc_path = tmp_path / "followup.txt"
doc_path.write_text("Do not inject this file body", encoding="utf-8")
captured_messages: list[list[dict]] = []
call_count = {"n": 0}
async def chat_with_retry(*, messages: list[dict], **kwargs: object) -> LLMResponse:
call_count["n"] += 1
captured_messages.append([dict(message) for message in messages])
return LLMResponse(content=f"answer-{call_count['n']}", tool_calls=[], usage={})
loop = _make_loop(tmp_path, ChannelsConfig(extract_document_text=False))
loop.provider.chat_with_retry = chat_with_retry
loop.tools.get_definitions = MagicMock(return_value=[])
def fail_extract_documents(content: str, media: list[str]) -> tuple[str, list[str]]:
raise AssertionError("document extraction should be disabled")
monkeypatch.setattr("nanobot.agent.loop.extract_documents", fail_extract_documents)
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
await pending_queue.put(
InboundMessage(
channel="cli",
sender_id="u",
chat_id="c",
content="check this",
media=[str(doc_path)],
)
)
final_content, _, _, _, had_injections = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
pending_queue=pending_queue,
)
assert final_content == "answer-2"
assert had_injections is True
injected_user_content = [
message["content"]
for message in captured_messages[-1]
if message.get("role") == "user" and isinstance(message.get("content"), str)
][-1]
assert "check this" in injected_user_content
assert f"[Attachment: {doc_path}]" in injected_user_content
assert "Do not inject this file body" not in injected_user_content
def test_document_extraction_disabled_still_preserves_images(tmp_path: Path) -> None:
image_path = tmp_path / "chart.png"
image_path.write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9kAAAAASUVORK5CYII="
)
)
doc_path = tmp_path / "report.txt"
doc_path.write_text("manual extraction target", encoding="utf-8")
content, media = reference_non_image_attachments(
"review these",
[str(image_path), str(doc_path)],
)
assert media == [str(image_path)]
assert f"[Attachment: {doc_path}]" in content
@@ -7,8 +7,11 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket.runtime import WebSocketChannel
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.session import webui_turns as wth
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
def _make_loop(tmp_path):
@@ -32,6 +35,7 @@ def _make_loop(tmp_path):
sessions=loop.sessions,
schedule_background=lambda coro: loop._schedule_background(coro),
).subscribe(loop.runtime_events)
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
loop.tools.get_definitions = MagicMock(return_value=[])
return loop
@@ -39,29 +43,51 @@ def _make_loop(tmp_path):
@pytest.mark.asyncio
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
loop = _make_loop(tmp_path)
response = await loop.process_direct(
"deliver reminder",
session_key="cron:reminder-1",
channel="websocket",
chat_id="chat-1",
gateway = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
loop.bus,
gateway=gateway,
)
assert response is not None
assert response.content == "done"
try:
response = await loop.process_direct(
"deliver reminder",
session_key="cron:reminder-1",
channel="websocket",
chat_id="chat-1",
)
events = []
while loop.bus.outbound_size:
events.append(await loop.bus.consume_outbound())
assert response is not None
assert response.content == "done"
statuses = [
event.event
for event in events
if isinstance(event.event, GoalStatusEvent)
]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
events = []
while loop.bus.outbound_size:
event = await loop.bus.consume_outbound()
events.append(event)
await channel.send(event)
status_messages = [
event
for event in events
if isinstance(event.event, GoalStatusEvent)
]
statuses = [event.event for event in status_messages]
assert [status.status for status in statuses] == ["running", "idle"]
assert isinstance(statuses[0].started_at, float)
assert statuses[1].started_at is None
owners = {
event.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
for event in status_messages
}
assert len(owners) == 1
assert wth.websocket_turn_wall_started_at("chat-1") is None
assert "chat-1" not in wth._WEBSOCKET_ACTIVE_TURNS
finally:
wth._WEBSOCKET_ACTIVE_TURNS.clear()
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
wth._WEBSOCKET_TURN_IDS.clear()
wth._WEBSOCKET_TURN_OWNERS.clear()
@pytest.mark.asyncio
+11 -1
View File
@@ -28,7 +28,10 @@ from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
def _make_loop(tmp_path: Path) -> AgentLoop:
@@ -903,6 +906,12 @@ class TestToolEventProgress:
turn_id = turn_ids.pop()
assert isinstance(turn_id, str)
assert turn_id.startswith("subagent:")
owners = {
message.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
for message in visible_events
}
assert len(owners) == 1
assert isinstance(owners.pop(), str)
assert all(
(message.channel, message.chat_id) == ("websocket", "chat-a")
and message.metadata.get("webui") is True
@@ -910,6 +919,7 @@ class TestToolEventProgress:
and set(message.metadata) <= {
"webui",
"_wants_stream",
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
"latency_ms",
}
+3 -4
View File
@@ -713,10 +713,9 @@ def test_unified_session_route_ignores_non_user_destinations(
assert session.metadata[LAST_CHANNEL_METADATA_KEY] == "telegram:existing"
# 1x1 PNG used by the media-persistence tests. ``extract_documents`` runs
# at the top of ``_process_message`` and filters ``msg.media`` down to
# paths that magic-byte-sniff as images, so the test fixture needs real
# bytes on disk (not just placeholder paths).
# 1x1 PNG used by the media-persistence tests. Attachment preparation filters
# ``msg.media`` down to paths that magic-byte-sniff as images, so the test
# fixture needs real bytes on disk (not just placeholder paths).
_PNG_1X1 = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
+9 -11
View File
@@ -308,19 +308,17 @@ class TestAppendHistoryHardCap:
entry = store.read_unprocessed_history(since_cursor=0)[0]
assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50
def test_oversize_warning_is_emitted_once(self, store, caplog):
def test_oversize_warning_is_emitted_once(self, store, monkeypatch):
"""Repeated oversized writes should warn only on the first occurrence."""
from loguru import logger as loguru_logger
records: list[str] = []
handler_id = loguru_logger.add(lambda m: records.append(m), level="WARNING")
try:
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1)
store.append_history(huge)
store.append_history(huge)
store.append_history(huge)
finally:
loguru_logger.remove(handler_id)
monkeypatch.setattr(
"nanobot.agent.memory.logger.warning",
lambda message, *args: records.append(message.format(*args)),
)
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1)
store.append_history(huge)
store.append_history(huge)
store.append_history(huge)
oversize_warnings = [r for r in records if "exceeds" in r and "chars" in r]
assert len(oversize_warnings) == 1
+3 -2
View File
@@ -891,7 +891,8 @@ def test_drop_malformed_tool_calls_trims_response():
tool_calls=[
ToolCallRequest(id="1", name=None, arguments={}),
ToolCallRequest(id="2", name="", arguments={}),
ToolCallRequest(id="3", name="read_file", arguments={}),
ToolCallRequest(id="3", name={"unexpected": "object"}, arguments={}),
ToolCallRequest(id="4", name="read_file", arguments={}),
],
finish_reason="tool_calls",
)
@@ -899,7 +900,7 @@ def test_drop_malformed_tool_calls_trims_response():
assert [tc.name for tc in response.tool_calls] == ["read_file"]
assert response.finish_reason == "tool_calls"
assert response.should_execute_tools is True
assert dropped == 2
assert dropped == 3
assert all_dropped is False
assert orig == "tool_calls"
+20
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -8,6 +9,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeModelChanged
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import GenerationSettings
@@ -127,6 +129,24 @@ def test_llm_runtime_surfaces_invalidated_config_errors(tmp_path: Path) -> None:
loop.llm_runtime()
def test_provider_snapshot_missing_env_reports_explicit_config_path(
tmp_path: Path,
monkeypatch,
) -> None:
name = "NANOBOT_TEST_REFRESH_MISSING_KEY"
monkeypatch.delenv(name, raising=False)
config_path = tmp_path / "custom.json"
config_path.write_text(
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
encoding="utf-8",
)
with pytest.raises(ConfigLoadError) as exc_info:
load_provider_snapshot(config_path)
assert exc_info.value.path == config_path
def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
base_provider = _provider("base-model")
fast_provider = _provider("fast-model")
+31
View File
@@ -387,6 +387,37 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
assert "beta" in always
def test_explicit_skill_references_resolve_available_enabled_names_in_order(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "ws"
skills_root = workspace / "skills"
skills_root.mkdir(parents=True)
_write_skill(skills_root, "alpha", body="# Alpha")
_write_skill(skills_root, "beta", body="# Beta")
_write_skill(
skills_root,
"blocked",
metadata_json={"requires": {"env": ["MISSING_SKILL_TEST_ENV"]}},
body="# Blocked",
)
builtin = tmp_path / "builtin"
builtin.mkdir()
monkeypatch.delenv("MISSING_SKILL_TEST_ENV", raising=False)
loader = SkillsLoader(
workspace,
builtin_skills_dir=builtin,
disabled_skills={"beta"},
)
invoked = loader.get_explicitly_invoked_skills(
"Use $alpha, then $unknown, $alpha again, $beta, and $blocked."
)
assert invoked == ["alpha"]
# -- multiline description tests (YAML folded > and literal |) -----------------
+3 -1
View File
@@ -73,7 +73,9 @@ class TestHandleStop:
task = asyncio.create_task(slow_task())
await asyncio.sleep(0)
loop._active_tasks["test:c1"] = {task}
active_tasks = {task}
loop._active_tasks["test:c1"] = active_tasks
task.add_done_callback(active_tasks.discard)
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
+141 -1
View File
@@ -1,12 +1,147 @@
from pathlib import Path
import pytest
from nanobot.agent.turn_delivery import TurnDeliveryFactory
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
def test_websocket_lifecycles_get_distinct_internal_owners(tmp_path: Path) -> None:
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
first_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-a",
content="first",
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
)
second_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-a",
content="second",
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
)
first = factory.create(first_msg, first_msg.session_key)
second = factory.create(second_msg, second_msg.session_key)
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
assert first_owner == first.delivery_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
assert first_owner != second_owner
assert first_owner != "attacker-reused-owner"
assert second_owner != "attacker-reused-owner"
assert WEBUI_TURN_METADATA_KEY not in first.lifecycle_message.metadata
assert first_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == first_owner
assert second_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == second_owner
def test_websocket_lifecycle_reuses_registered_ingress_owner(tmp_path: Path) -> None:
from nanobot.session import webui_turns as wth
owner = wth.register_queued_websocket_turn_if_idle("chat-queued", "turn-queued")
assert owner is not None
msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-queued",
content="queued",
metadata={
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
WEBUI_TURN_METADATA_KEY: "turn-queued",
},
)
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
try:
delivery = factory.create(msg, msg.session_key)
assert delivery.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
assert msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
finally:
wth.clear_websocket_turn_if_current("chat-queued", owner)
@pytest.mark.asyncio
async def test_same_chat_different_sessions_restore_previous_active_projection(
tmp_path: Path,
) -> None:
from unittest.mock import AsyncMock, MagicMock
from nanobot.session import webui_turns as wth
factory = TurnDeliveryFactory(
MessageBus(),
RuntimeEventBus(),
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
)
first_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="shared-chat",
content="first",
metadata={WEBUI_TURN_METADATA_KEY: "turn-first"},
session_key_override="websocket:session-first",
)
second_msg = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="shared-chat",
content="second",
metadata={WEBUI_TURN_METADATA_KEY: "turn-second"},
session_key_override="websocket:session-second",
)
first = factory.create(first_msg, first_msg.session_key)
second = factory.create(second_msg, second_msg.session_key)
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
bus = MagicMock()
bus.publish_outbound = AsyncMock()
try:
await wth.publish_turn_run_status(
bus,
first.lifecycle_message,
"running",
started_at=100.0,
)
await wth.publish_turn_run_status(
bus,
second.lifecycle_message,
"running",
started_at=200.0,
)
assert wth.websocket_turn_wall_started_at("shared-chat") == 200.0
assert wth.websocket_turn_id("shared-chat") == "turn-second"
assert wth.clear_websocket_turn_if_current("shared-chat", second_owner) is True
assert wth.websocket_turn_wall_started_at("shared-chat") == 100.0
assert wth.websocket_turn_id("shared-chat") == "turn-first"
assert wth._WEBSOCKET_TURN_OWNERS["shared-chat"] == first_owner
assert wth.clear_websocket_turn_if_current("shared-chat", first_owner) is True
assert wth.websocket_turn_wall_started_at("shared-chat") is None
finally:
wth._WEBSOCKET_ACTIVE_TURNS.pop("shared-chat", None)
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("shared-chat", None)
wth._WEBSOCKET_TURN_IDS.pop("shared-chat", None)
wth._WEBSOCKET_TURN_OWNERS.pop("shared-chat", None)
def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> None:
@@ -45,6 +180,7 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
assert set(first_visible_route.metadata) == {
"webui",
"_wants_stream",
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
}
assert first_visible_route.metadata["webui"] is True
@@ -54,6 +190,10 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
assert first_turn_id.startswith("subagent:")
assert second_turn_id.startswith("subagent:")
assert first_turn_id != second_turn_id
assert (
first_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
!= second_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
)
assert msg.metadata == {
"injected_event": "subagent_result",
"subagent_task_id": "sub-1",
+22
View File
@@ -14,6 +14,23 @@ class RecordingHook(AgentHook):
self._events.append(f"{self._label}:{context.iteration}")
def test_turn_hook_context_preserves_legacy_positional_arguments(tmp_path) -> None:
context = AgentTurnHookContext(
None,
tmp_path,
"sdk",
"chat-a",
"message-1",
"sdk:chat-a",
{"trusted": True},
True,
)
assert context.metadata == {"trusted": True}
assert context.ephemeral is True
assert context.attributes == {}
@pytest.mark.asyncio
async def test_turn_hook_builder_runs_progress_hook_before_extra_hooks() -> None:
events: list[str] = []
@@ -65,6 +82,7 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
session_key="websocket:chat-1",
workspace=tmp_path,
metadata={"source": "test"},
attributes={"tenant": "acme"},
registered_hook_factories=[factory("registered_factory")],
registered_hooks=[RecordingHook(events, "registered")],
turn_hook_factories=[factory("turn_factory")],
@@ -92,6 +110,10 @@ async def test_turn_hook_builder_runs_factories_with_matching_registration_order
{"source": "test"},
{"source": "test"},
]
assert [context.attributes for context in captured] == [
{"tenant": "acme"},
{"tenant": "acme"},
]
@pytest.mark.asyncio