mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +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
|
||||
|
||||
Reference in New Issue
Block a user