Merge origin/main into fix/sanitize-messages-non-claude
Resolved conflict in azure_openai_provider.py by keeping main's Responses API implementation (role alternation not needed for the Responses API input format). Made-with: Cursor
This commit is contained in:
@@ -506,7 +506,7 @@ class TestNewCommandArchival:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_clears_session_immediately_even_if_archive_fails(self, tmp_path: Path) -> None:
|
||||
"""/new clears session immediately; archive_messages retries until raw dump."""
|
||||
"""/new clears session immediately; archive is fire-and-forget."""
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop = self._make_loop(tmp_path)
|
||||
@@ -518,12 +518,12 @@ class TestNewCommandArchival:
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _failing_consolidate(_messages) -> bool:
|
||||
async def _failing_summarize(_messages) -> bool:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return False
|
||||
|
||||
loop.memory_consolidator.consolidate_messages = _failing_consolidate # type: ignore[method-assign]
|
||||
loop.consolidator.archive = _failing_summarize # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
response = await loop._process_message(new_msg)
|
||||
@@ -535,7 +535,7 @@ class TestNewCommandArchival:
|
||||
assert len(session_after.messages) == 0
|
||||
|
||||
await loop.close_mcp()
|
||||
assert call_count == 3 # retried up to raw-archive threshold
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_archives_only_unconsolidated_messages(self, tmp_path: Path) -> None:
|
||||
@@ -551,12 +551,12 @@ class TestNewCommandArchival:
|
||||
|
||||
archived_count = -1
|
||||
|
||||
async def _fake_consolidate(messages) -> bool:
|
||||
async def _fake_summarize(messages) -> bool:
|
||||
nonlocal archived_count
|
||||
archived_count = len(messages)
|
||||
return True
|
||||
|
||||
loop.memory_consolidator.consolidate_messages = _fake_consolidate # type: ignore[method-assign]
|
||||
loop.consolidator.archive = _fake_summarize # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
response = await loop._process_message(new_msg)
|
||||
@@ -578,10 +578,10 @@ class TestNewCommandArchival:
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def _ok_consolidate(_messages) -> bool:
|
||||
async def _ok_summarize(_messages) -> bool:
|
||||
return True
|
||||
|
||||
loop.memory_consolidator.consolidate_messages = _ok_consolidate # type: ignore[method-assign]
|
||||
loop.consolidator.archive = _ok_summarize # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
response = await loop._process_message(new_msg)
|
||||
@@ -604,12 +604,12 @@ class TestNewCommandArchival:
|
||||
|
||||
archived = asyncio.Event()
|
||||
|
||||
async def _slow_consolidate(_messages) -> bool:
|
||||
async def _slow_summarize(_messages) -> bool:
|
||||
await asyncio.sleep(0.1)
|
||||
archived.set()
|
||||
return True
|
||||
|
||||
loop.memory_consolidator.consolidate_messages = _slow_consolidate # type: ignore[method-assign]
|
||||
loop.consolidator.archive = _slow_summarize # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
await loop._process_message(new_msg)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the lightweight Consolidator — append-only to HISTORY.md."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return MemoryStore(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def consolidator(store, mock_provider):
|
||||
sessions = MagicMock()
|
||||
sessions.save = MagicMock()
|
||||
return Consolidator(
|
||||
store=store,
|
||||
provider=mock_provider,
|
||||
model="test-model",
|
||||
sessions=sessions,
|
||||
context_window_tokens=1000,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
max_completion_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
class TestConsolidatorSummarize:
|
||||
async def test_summarize_appends_to_history(self, consolidator, mock_provider, store):
|
||||
"""Consolidator should call LLM to summarize, then append to HISTORY.md."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="User fixed a bug in the auth module."
|
||||
)
|
||||
messages = [
|
||||
{"role": "user", "content": "fix the auth bug"},
|
||||
{"role": "assistant", "content": "Done, fixed the race condition."},
|
||||
]
|
||||
result = await consolidator.archive(messages)
|
||||
assert result is True
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
|
||||
async def test_summarize_raw_dumps_on_llm_failure(self, consolidator, mock_provider, store):
|
||||
"""On LLM failure, raw-dump messages to HISTORY.md."""
|
||||
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
result = await consolidator.archive(messages)
|
||||
assert result is True # always succeeds
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert "[RAW]" in entries[0]["content"]
|
||||
|
||||
async def test_summarize_skips_empty_messages(self, consolidator):
|
||||
result = await consolidator.archive([])
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestConsolidatorTokenBudget:
|
||||
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
|
||||
"""No consolidation when tokens are within budget."""
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.key = "test:key"
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
||||
consolidator.archive = AsyncMock(return_value=True)
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
consolidator.archive.assert_not_called()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime as real_datetime
|
||||
from importlib.resources import files as pkg_files
|
||||
from pathlib import Path
|
||||
@@ -47,6 +48,19 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) ->
|
||||
assert prompt1 == prompt2
|
||||
|
||||
|
||||
def test_system_prompt_reflects_current_dream_memory_contract(tmp_path) -> None:
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
|
||||
assert "memory/history.jsonl" in prompt
|
||||
assert "automatically managed by Dream" in prompt
|
||||
assert "do not edit directly" in prompt
|
||||
assert "memory/HISTORY.md" not in prompt
|
||||
assert "write important facts here" not in prompt
|
||||
|
||||
|
||||
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
||||
"""Runtime metadata should be merged with the user message."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
@@ -71,3 +85,137 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
||||
assert "Channel: cli" in user_content
|
||||
assert "Chat ID: direct" in user_content
|
||||
assert "Return exactly: OK" in user_content
|
||||
|
||||
|
||||
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
builder.memory.append_history("User asked about weather in Tokyo")
|
||||
builder.memory.append_history("Agent fetched forecast via web_search")
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" in prompt
|
||||
assert "User asked about weather in Tokyo" in prompt
|
||||
assert "Agent fetched forecast via web_search" in prompt
|
||||
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
|
||||
|
||||
|
||||
def test_recent_history_capped_at_max(tmp_path) -> None:
|
||||
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
for i in range(builder._MAX_RECENT_HISTORY + 20):
|
||||
builder.memory.append_history(f"entry-{i}")
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "entry-0" not in prompt
|
||||
assert "entry-19" not in prompt
|
||||
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
|
||||
|
||||
|
||||
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
|
||||
"""If Dream has consumed everything, no Recent History section should appear."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
cursor = builder.memory.append_history("already processed entry")
|
||||
builder.memory.set_last_dream_cursor(cursor)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" not in prompt
|
||||
|
||||
|
||||
def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
|
||||
"""When Dream has processed some entries, only the unprocessed ones appear."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
c1 = builder.memory.append_history("old conversation about Python")
|
||||
c2 = builder.memory.append_history("old conversation about Rust")
|
||||
builder.memory.append_history("recent question about Docker")
|
||||
builder.memory.append_history("recent question about K8s")
|
||||
|
||||
builder.memory.set_last_dream_cursor(c2)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" in prompt
|
||||
assert "old conversation about Python" not in prompt
|
||||
assert "old conversation about Rust" not in prompt
|
||||
assert "recent question about Docker" in prompt
|
||||
assert "recent question about K8s" in prompt
|
||||
|
||||
|
||||
def test_execution_rules_in_system_prompt(tmp_path) -> None:
|
||||
"""New execution rules should appear in the system prompt."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "Act, don't narrate" in prompt
|
||||
assert "Read before you write" in prompt
|
||||
assert "verify the result" in prompt
|
||||
|
||||
|
||||
def test_channel_format_hint_telegram(tmp_path) -> None:
|
||||
"""Telegram channel should get messaging-app format hint."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt(channel="telegram")
|
||||
assert "Format Hint" in prompt
|
||||
assert "messaging app" in prompt
|
||||
|
||||
|
||||
def test_channel_format_hint_whatsapp(tmp_path) -> None:
|
||||
"""WhatsApp should get plain-text format hint."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt(channel="whatsapp")
|
||||
assert "Format Hint" in prompt
|
||||
assert "plain text only" in prompt
|
||||
|
||||
|
||||
def test_channel_format_hint_absent_for_unknown(tmp_path) -> None:
|
||||
"""Unknown or None channel should not inject a format hint."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt(channel=None)
|
||||
assert "Format Hint" not in prompt
|
||||
|
||||
prompt2 = builder.build_system_prompt(channel="feishu")
|
||||
assert "Format Hint" not in prompt2
|
||||
|
||||
|
||||
def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
|
||||
"""build_messages should pass channel through to build_system_prompt."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[], current_message="hi",
|
||||
channel="telegram", chat_id="123",
|
||||
)
|
||||
system = messages[0]["content"]
|
||||
assert "Format Hint" in system
|
||||
assert "messaging app" in system
|
||||
|
||||
|
||||
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[{"role": "assistant", "content": "previous result"}],
|
||||
current_message="subagent result",
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
current_role="assistant",
|
||||
)
|
||||
|
||||
for left, right in zip(messages, messages[1:]):
|
||||
assert not (left.get("role") == right.get("role") == "assistant")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from nanobot.agent.memory import Dream, MemoryStore
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
s = MemoryStore(tmp_path)
|
||||
s.write_soul("# Soul\n- Helpful")
|
||||
s.write_user("# User\n- Developer")
|
||||
s.write_memory("# Memory\n- Project X active")
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runner():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dream(store, mock_provider, mock_runner):
|
||||
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
||||
d._runner = mock_runner
|
||||
return d
|
||||
|
||||
|
||||
def _make_run_result(
|
||||
stop_reason="completed",
|
||||
final_content=None,
|
||||
tool_events=None,
|
||||
usage=None,
|
||||
):
|
||||
return AgentRunResult(
|
||||
final_content=final_content or stop_reason,
|
||||
stop_reason=stop_reason,
|
||||
messages=[],
|
||||
tools_used=[],
|
||||
usage={},
|
||||
tool_events=tool_events or [],
|
||||
)
|
||||
|
||||
|
||||
class TestDreamRun:
|
||||
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should not call LLM when there's nothing to process."""
|
||||
result = await dream.run()
|
||||
assert result is False
|
||||
mock_provider.chat_with_retry.assert_not_called()
|
||||
mock_runner.run.assert_not_called()
|
||||
|
||||
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
||||
store.append_history("User prefers dark mode")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
||||
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
||||
))
|
||||
result = await dream.run()
|
||||
assert result is True
|
||||
mock_runner.run.assert_called_once()
|
||||
spec = mock_runner.run.call_args[0][0]
|
||||
assert spec.max_iterations == 10
|
||||
assert spec.fail_on_tool_error is False
|
||||
|
||||
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should advance the cursor after processing."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
assert store.get_last_dream_cursor() == 2
|
||||
|
||||
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should compact history after processing."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert all(e["cursor"] > 0 for e in entries)
|
||||
|
||||
@@ -184,17 +184,22 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
messages = [{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
"extra_content": GEMINI_EXTRA,
|
||||
}],
|
||||
}]
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
"extra_content": GEMINI_EXTRA,
|
||||
}],
|
||||
},
|
||||
{"role": "tool", "content": "ok", "tool_call_id": "call_1"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
|
||||
sanitized = provider._sanitize_messages(messages)
|
||||
|
||||
assert sanitized[0]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for GitStore — git-backed version control for memory files."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.utils.gitstore import GitStore, CommitInfo
|
||||
|
||||
|
||||
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git(tmp_path):
|
||||
"""Uninitialized GitStore."""
|
||||
return GitStore(tmp_path, tracked_files=TRACKED)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_ready(git):
|
||||
"""Initialized GitStore with one initial commit."""
|
||||
git.init()
|
||||
return git
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_not_initialized_by_default(self, git, tmp_path):
|
||||
assert not git.is_initialized()
|
||||
assert not (tmp_path / ".git").is_dir()
|
||||
|
||||
def test_init_creates_git_dir(self, git, tmp_path):
|
||||
assert git.init()
|
||||
assert (tmp_path / ".git").is_dir()
|
||||
|
||||
def test_init_idempotent(self, git_ready):
|
||||
assert not git_ready.init()
|
||||
|
||||
def test_init_creates_gitignore(self, git_ready):
|
||||
gi = git_ready._workspace / ".gitignore"
|
||||
assert gi.exists()
|
||||
content = gi.read_text(encoding="utf-8")
|
||||
for f in TRACKED:
|
||||
assert f"!{f}" in content
|
||||
|
||||
def test_init_touches_tracked_files(self, git_ready):
|
||||
for f in TRACKED:
|
||||
assert (git_ready._workspace / f).exists()
|
||||
|
||||
def test_init_makes_initial_commit(self, git_ready):
|
||||
commits = git_ready.log()
|
||||
assert len(commits) == 1
|
||||
assert "init" in commits[0].message
|
||||
|
||||
|
||||
class TestBuildGitignore:
|
||||
def test_subdirectory_dirs(self, git):
|
||||
content = git._build_gitignore()
|
||||
assert "!memory/\n" in content
|
||||
for f in TRACKED:
|
||||
assert f"!{f}\n" in content
|
||||
assert content.startswith("/*\n")
|
||||
|
||||
def test_root_level_files_no_dir_entries(self, tmp_path):
|
||||
gs = GitStore(tmp_path, tracked_files=["a.md", "b.md"])
|
||||
content = gs._build_gitignore()
|
||||
assert "!a.md\n" in content
|
||||
assert "!b.md\n" in content
|
||||
dir_lines = [l for l in content.split("\n") if l.startswith("!") and l.endswith("/")]
|
||||
assert dir_lines == []
|
||||
|
||||
|
||||
class TestAutoCommit:
|
||||
def test_returns_none_when_not_initialized(self, git):
|
||||
assert git.auto_commit("test") is None
|
||||
|
||||
def test_commits_file_change(self, git_ready):
|
||||
(git_ready._workspace / "SOUL.md").write_text("updated", encoding="utf-8")
|
||||
sha = git_ready.auto_commit("update soul")
|
||||
assert sha is not None
|
||||
assert len(sha) == 8
|
||||
|
||||
def test_returns_none_when_no_changes(self, git_ready):
|
||||
assert git_ready.auto_commit("no change") is None
|
||||
|
||||
def test_commit_appears_in_log(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
|
||||
sha = git_ready.auto_commit("update soul")
|
||||
commits = git_ready.log()
|
||||
assert len(commits) == 2
|
||||
assert commits[0].sha == sha
|
||||
|
||||
def test_does_not_create_empty_commits(self, git_ready):
|
||||
git_ready.auto_commit("nothing 1")
|
||||
git_ready.auto_commit("nothing 2")
|
||||
assert len(git_ready.log()) == 1 # only init commit
|
||||
|
||||
|
||||
class TestLog:
|
||||
def test_empty_when_not_initialized(self, git):
|
||||
assert git.log() == []
|
||||
|
||||
def test_newest_first(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
for i in range(3):
|
||||
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
|
||||
git_ready.auto_commit(f"commit {i}")
|
||||
|
||||
commits = git_ready.log()
|
||||
assert len(commits) == 4 # init + 3
|
||||
assert "commit 2" in commits[0].message
|
||||
assert "init" in commits[-1].message
|
||||
|
||||
def test_max_entries(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
for i in range(10):
|
||||
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
|
||||
git_ready.auto_commit(f"c{i}")
|
||||
assert len(git_ready.log(max_entries=3)) == 3
|
||||
|
||||
def test_commit_info_fields(self, git_ready):
|
||||
c = git_ready.log()[0]
|
||||
assert isinstance(c, CommitInfo)
|
||||
assert len(c.sha) == 8
|
||||
assert c.timestamp
|
||||
assert c.message
|
||||
|
||||
|
||||
class TestDiffCommits:
|
||||
def test_empty_when_not_initialized(self, git):
|
||||
assert git.diff_commits("a", "b") == ""
|
||||
|
||||
def test_diff_between_two_commits(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("original", encoding="utf-8")
|
||||
git_ready.auto_commit("v1")
|
||||
(ws / "SOUL.md").write_text("modified", encoding="utf-8")
|
||||
git_ready.auto_commit("v2")
|
||||
|
||||
commits = git_ready.log()
|
||||
diff = git_ready.diff_commits(commits[1].sha, commits[0].sha)
|
||||
assert "modified" in diff
|
||||
|
||||
def test_invalid_sha_returns_empty(self, git_ready):
|
||||
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
|
||||
|
||||
|
||||
class TestFindCommit:
|
||||
def test_finds_by_prefix(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
|
||||
sha = git_ready.auto_commit("v2")
|
||||
found = git_ready.find_commit(sha[:4])
|
||||
assert found is not None
|
||||
assert found.sha == sha
|
||||
|
||||
def test_returns_none_for_unknown(self, git_ready):
|
||||
assert git_ready.find_commit("deadbeef") is None
|
||||
|
||||
|
||||
class TestShowCommitDiff:
|
||||
def test_returns_commit_with_diff(self, git_ready):
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("content", encoding="utf-8")
|
||||
sha = git_ready.auto_commit("add content")
|
||||
result = git_ready.show_commit_diff(sha)
|
||||
assert result is not None
|
||||
commit, diff = result
|
||||
assert commit.sha == sha
|
||||
assert "content" in diff
|
||||
|
||||
def test_first_commit_has_empty_diff(self, git_ready):
|
||||
init_sha = git_ready.log()[-1].sha
|
||||
result = git_ready.show_commit_diff(init_sha)
|
||||
assert result is not None
|
||||
_, diff = result
|
||||
assert diff == ""
|
||||
|
||||
def test_returns_none_for_unknown(self, git_ready):
|
||||
assert git_ready.show_commit_diff("deadbeef") is None
|
||||
|
||||
|
||||
class TestCommitInfoFormat:
|
||||
def test_format_with_diff(self):
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
c = CommitInfo(sha="abcd1234", message="test commit\nsecond line", timestamp="2026-04-02 12:00")
|
||||
result = c.format(diff="some diff")
|
||||
assert "test commit" in result
|
||||
assert "`abcd1234`" in result
|
||||
assert "some diff" in result
|
||||
|
||||
def test_format_without_diff(self):
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
c = CommitInfo(sha="abcd1234", message="test", timestamp="2026-04-02 12:00")
|
||||
result = c.format()
|
||||
assert "(no file changes)" in result
|
||||
|
||||
|
||||
class TestRevert:
|
||||
def test_returns_none_when_not_initialized(self, git):
|
||||
assert git.revert("abc") is None
|
||||
|
||||
def test_undoes_commit_changes(self, git_ready):
|
||||
"""revert(sha) should undo the given commit by restoring to its parent."""
|
||||
ws = git_ready._workspace
|
||||
(ws / "SOUL.md").write_text("v2 content", encoding="utf-8")
|
||||
git_ready.auto_commit("v2")
|
||||
|
||||
commits = git_ready.log()
|
||||
# commits[0] = v2 (HEAD), commits[1] = init
|
||||
# Revert v2 → restore to init's state (empty SOUL.md)
|
||||
new_sha = git_ready.revert(commits[0].sha)
|
||||
assert new_sha is not None
|
||||
assert (ws / "SOUL.md").read_text(encoding="utf-8") == ""
|
||||
|
||||
def test_root_commit_returns_none(self, git_ready):
|
||||
"""Cannot revert the root commit (no parent to restore to)."""
|
||||
commits = git_ready.log()
|
||||
assert len(commits) == 1
|
||||
assert git_ready.revert(commits[0].sha) is None
|
||||
|
||||
def test_invalid_sha_returns_none(self, git_ready):
|
||||
assert git_ready.revert("deadbeef") is None
|
||||
|
||||
|
||||
class TestMemoryStoreGitProperty:
|
||||
def test_git_property_exposes_gitstore(self, tmp_path):
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
store = MemoryStore(tmp_path)
|
||||
assert isinstance(store.git, GitStore)
|
||||
|
||||
def test_git_property_is_same_object(self, tmp_path):
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
store = MemoryStore(tmp_path)
|
||||
assert store.git is store._git
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Tests for CompositeHook fan-out, error isolation, and integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
|
||||
|
||||
|
||||
def _ctx() -> AgentHookContext:
|
||||
return AgentHookContext(iteration=0, messages=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fan-out: every hook is called in order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_fans_out_before_iteration():
|
||||
calls: list[str] = []
|
||||
|
||||
class H(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
calls.append(f"A:{context.iteration}")
|
||||
|
||||
class H2(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
calls.append(f"B:{context.iteration}")
|
||||
|
||||
hook = CompositeHook([H(), H2()])
|
||||
ctx = _ctx()
|
||||
await hook.before_iteration(ctx)
|
||||
assert calls == ["A:0", "B:0"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_fans_out_all_async_methods():
|
||||
"""Verify all async methods fan out to every hook."""
|
||||
events: list[str] = []
|
||||
|
||||
class RecordingHook(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
events.append("before_iteration")
|
||||
|
||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||
events.append(f"on_stream:{delta}")
|
||||
|
||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||
events.append(f"on_stream_end:{resuming}")
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
events.append("before_execute_tools")
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
events.append("after_iteration")
|
||||
|
||||
hook = CompositeHook([RecordingHook(), RecordingHook()])
|
||||
ctx = _ctx()
|
||||
|
||||
await hook.before_iteration(ctx)
|
||||
await hook.on_stream(ctx, "hi")
|
||||
await hook.on_stream_end(ctx, resuming=True)
|
||||
await hook.before_execute_tools(ctx)
|
||||
await hook.after_iteration(ctx)
|
||||
|
||||
assert events == [
|
||||
"before_iteration", "before_iteration",
|
||||
"on_stream:hi", "on_stream:hi",
|
||||
"on_stream_end:True", "on_stream_end:True",
|
||||
"before_execute_tools", "before_execute_tools",
|
||||
"after_iteration", "after_iteration",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error isolation: one hook raises, others still run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_error_isolation_before_iteration():
|
||||
calls: list[str] = []
|
||||
|
||||
class Bad(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
class Good(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
calls.append("good")
|
||||
|
||||
hook = CompositeHook([Bad(), Good()])
|
||||
await hook.before_iteration(_ctx())
|
||||
assert calls == ["good"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_error_isolation_on_stream():
|
||||
calls: list[str] = []
|
||||
|
||||
class Bad(AgentHook):
|
||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||
raise RuntimeError("stream-boom")
|
||||
|
||||
class Good(AgentHook):
|
||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||
calls.append(delta)
|
||||
|
||||
hook = CompositeHook([Bad(), Good()])
|
||||
await hook.on_stream(_ctx(), "delta")
|
||||
assert calls == ["delta"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_error_isolation_all_async():
|
||||
"""Error isolation for on_stream_end, before_execute_tools, after_iteration."""
|
||||
calls: list[str] = []
|
||||
|
||||
class Bad(AgentHook):
|
||||
async def on_stream_end(self, context, *, resuming):
|
||||
raise RuntimeError("err")
|
||||
async def before_execute_tools(self, context):
|
||||
raise RuntimeError("err")
|
||||
async def after_iteration(self, context):
|
||||
raise RuntimeError("err")
|
||||
|
||||
class Good(AgentHook):
|
||||
async def on_stream_end(self, context, *, resuming):
|
||||
calls.append("on_stream_end")
|
||||
async def before_execute_tools(self, context):
|
||||
calls.append("before_execute_tools")
|
||||
async def after_iteration(self, context):
|
||||
calls.append("after_iteration")
|
||||
|
||||
hook = CompositeHook([Bad(), Good()])
|
||||
ctx = _ctx()
|
||||
await hook.on_stream_end(ctx, resuming=False)
|
||||
await hook.before_execute_tools(ctx)
|
||||
await hook.after_iteration(ctx)
|
||||
assert calls == ["on_stream_end", "before_execute_tools", "after_iteration"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# finalize_content: pipeline semantics (no error isolation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_composite_finalize_content_pipeline():
|
||||
class Upper(AgentHook):
|
||||
def finalize_content(self, context, content):
|
||||
return content.upper() if content else content
|
||||
|
||||
class Suffix(AgentHook):
|
||||
def finalize_content(self, context, content):
|
||||
return (content + "!") if content else content
|
||||
|
||||
hook = CompositeHook([Upper(), Suffix()])
|
||||
result = hook.finalize_content(_ctx(), "hello")
|
||||
assert result == "HELLO!"
|
||||
|
||||
|
||||
def test_composite_finalize_content_none_passthrough():
|
||||
hook = CompositeHook([AgentHook()])
|
||||
assert hook.finalize_content(_ctx(), None) is None
|
||||
|
||||
|
||||
def test_composite_finalize_content_ordering():
|
||||
"""First hook transforms first, result feeds second hook."""
|
||||
steps: list[str] = []
|
||||
|
||||
class H1(AgentHook):
|
||||
def finalize_content(self, context, content):
|
||||
steps.append(f"H1:{content}")
|
||||
return content.upper()
|
||||
|
||||
class H2(AgentHook):
|
||||
def finalize_content(self, context, content):
|
||||
steps.append(f"H2:{content}")
|
||||
return content + "!"
|
||||
|
||||
hook = CompositeHook([H1(), H2()])
|
||||
result = hook.finalize_content(_ctx(), "hi")
|
||||
assert result == "HI!"
|
||||
assert steps == ["H1:hi", "H2:HI"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wants_streaming: any-semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_composite_wants_streaming_any_true():
|
||||
class No(AgentHook):
|
||||
def wants_streaming(self):
|
||||
return False
|
||||
|
||||
class Yes(AgentHook):
|
||||
def wants_streaming(self):
|
||||
return True
|
||||
|
||||
hook = CompositeHook([No(), Yes(), No()])
|
||||
assert hook.wants_streaming() is True
|
||||
|
||||
|
||||
def test_composite_wants_streaming_all_false():
|
||||
hook = CompositeHook([AgentHook(), AgentHook()])
|
||||
assert hook.wants_streaming() is False
|
||||
|
||||
|
||||
def test_composite_wants_streaming_empty():
|
||||
hook = CompositeHook([])
|
||||
assert hook.wants_streaming() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty hooks list: behaves like no-op AgentHook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_empty_hooks_no_ops():
|
||||
hook = CompositeHook([])
|
||||
ctx = _ctx()
|
||||
await hook.before_iteration(ctx)
|
||||
await hook.on_stream(ctx, "delta")
|
||||
await hook.on_stream_end(ctx, resuming=False)
|
||||
await hook.before_execute_tools(ctx)
|
||||
await hook.after_iteration(ctx)
|
||||
assert hook.finalize_content(ctx, "test") == "test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_supports_legacy_hook_init_without_super():
|
||||
calls: list[str] = []
|
||||
|
||||
class LegacyHook(AgentHook):
|
||||
def __init__(self, label: str) -> None:
|
||||
self.label = label
|
||||
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
calls.append(self.label)
|
||||
|
||||
hook = CompositeHook([LegacyHook("legacy")])
|
||||
await hook.before_iteration(_ctx())
|
||||
assert calls == ["legacy"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_composite_can_wrap_another_composite():
|
||||
calls: list[str] = []
|
||||
|
||||
class Inner(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
calls.append("inner")
|
||||
|
||||
hook = CompositeHook([CompositeHook([Inner()])])
|
||||
await hook.before_iteration(_ctx())
|
||||
assert calls == ["inner"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: AgentLoop with extra hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_loop(tmp_path, hooks=None):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation.max_tokens = 4096
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr, \
|
||||
patch("nanobot.agent.loop.Consolidator"), \
|
||||
patch("nanobot.agent.loop.Dream"):
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, hooks=hooks,
|
||||
)
|
||||
return loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_loop_extra_hook_receives_calls(tmp_path):
|
||||
"""Extra hook passed to AgentLoop is called alongside core LoopHook."""
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
class TrackingHook(AgentHook):
|
||||
async def before_iteration(self, context):
|
||||
events.append(f"before_iter:{context.iteration}")
|
||||
|
||||
async def after_iteration(self, context):
|
||||
events.append(f"after_iter:{context.iteration}")
|
||||
|
||||
loop = _make_loop(tmp_path, hooks=[TrackingHook()])
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="done", tool_calls=[], usage={})
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
content, tools_used, messages = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
|
||||
assert content == "done"
|
||||
assert "before_iter:0" in events
|
||||
assert "after_iter:0" in events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_loop_extra_hook_error_isolation(tmp_path):
|
||||
"""A faulty extra hook does not crash the agent loop."""
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
class BadHook(AgentHook):
|
||||
async def before_iteration(self, context):
|
||||
raise RuntimeError("I am broken")
|
||||
|
||||
loop = _make_loop(tmp_path, hooks=[BadHook()])
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="still works", tool_calls=[], usage={})
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
content, _, _ = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
|
||||
assert content == "still works"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_loop_extra_hooks_do_not_swallow_loop_hook_errors(tmp_path):
|
||||
"""Extra hooks must not change the core LoopHook failure behavior."""
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
loop = _make_loop(tmp_path, hooks=[AgentHook()])
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})],
|
||||
usage={},
|
||||
))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
|
||||
async def bad_progress(*args, **kwargs):
|
||||
raise RuntimeError("progress failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="progress failed"):
|
||||
await loop._run_agent_loop([], on_progress=bad_progress)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_loop_no_hooks_backward_compat(tmp_path):
|
||||
"""Without hooks param, behavior is identical to before."""
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})],
|
||||
))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
loop.max_iterations = 2
|
||||
|
||||
content, tools_used, _ = await loop._run_agent_loop([])
|
||||
assert content == (
|
||||
"I reached the maximum number of tool call iterations (2) "
|
||||
"without completing the task. You can try breaking the task into smaller steps."
|
||||
)
|
||||
assert tools_used == ["list_dir", "list_dir"]
|
||||
@@ -26,24 +26,24 @@ def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -
|
||||
context_window_tokens=context_window_tokens,
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.memory_consolidator._SAFETY_BUFFER = 0
|
||||
loop.consolidator._SAFETY_BUFFER = 0
|
||||
return loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
|
||||
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
|
||||
loop.memory_consolidator.consolidate_messages.assert_not_awaited()
|
||||
loop.consolidator.archive.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypatch) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
|
||||
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
|
||||
@@ -55,13 +55,13 @@ async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypat
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
|
||||
assert loop.memory_consolidator.consolidate_messages.await_count >= 1
|
||||
assert loop.consolidator.archive.await_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path, monkeypatch) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
|
||||
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
@@ -76,9 +76,9 @@ async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path
|
||||
token_map = {"u1": 120, "a1": 120, "u2": 120, "a2": 120, "u3": 120}
|
||||
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda message: token_map[message["content"]])
|
||||
|
||||
await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
archived_chunk = loop.memory_consolidator.consolidate_messages.await_args.args[0]
|
||||
archived_chunk = loop.consolidator.archive.await_args.args[0]
|
||||
assert [message["content"] for message in archived_chunk] == ["u1", "a1", "u2", "a2"]
|
||||
assert session.last_consolidated == 4
|
||||
|
||||
@@ -87,7 +87,7 @@ async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path
|
||||
async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> None:
|
||||
"""Verify maybe_consolidate_by_tokens keeps looping until under threshold."""
|
||||
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
|
||||
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
@@ -110,12 +110,12 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
|
||||
return (300, "test")
|
||||
return (80, "test")
|
||||
|
||||
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
|
||||
|
||||
await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
assert loop.memory_consolidator.consolidate_messages.await_count == 2
|
||||
assert loop.consolidator.archive.await_count == 2
|
||||
assert session.last_consolidated == 6
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
|
||||
async def test_consolidation_continues_below_trigger_until_half_target(tmp_path, monkeypatch) -> None:
|
||||
"""Once triggered, consolidation should continue until it drops below half threshold."""
|
||||
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
|
||||
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
@@ -147,12 +147,12 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
|
||||
return (150, "test")
|
||||
return (80, "test")
|
||||
|
||||
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
|
||||
|
||||
await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
assert loop.memory_consolidator.consolidate_messages.await_count == 2
|
||||
assert loop.consolidator.archive.await_count == 2
|
||||
assert session.last_consolidated == 6
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
|
||||
async def track_consolidate(messages):
|
||||
order.append("consolidate")
|
||||
return True
|
||||
loop.memory_consolidator.consolidate_messages = track_consolidate # type: ignore[method-assign]
|
||||
loop.consolidator.archive = track_consolidate # type: ignore[method-assign]
|
||||
|
||||
async def track_llm(*args, **kwargs):
|
||||
order.append("llm")
|
||||
@@ -187,7 +187,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
|
||||
def mock_estimate(_session):
|
||||
call_count[0] += 1
|
||||
return (1000 if call_count[0] <= 1 else 80, "test")
|
||||
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ from nanobot.session.manager import Session
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
loop = AgentLoop.__new__(AgentLoop)
|
||||
loop._TOOL_RESULT_MAX_CHARS = AgentLoop._TOOL_RESULT_MAX_CHARS
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
loop.max_tool_result_chars = AgentDefaults().max_tool_result_chars
|
||||
return loop
|
||||
|
||||
|
||||
@@ -72,3 +74,129 @@ def test_save_turn_keeps_tool_results_under_16k() -> None:
|
||||
)
|
||||
|
||||
assert session.messages[0]["content"] == content
|
||||
|
||||
|
||||
def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(
|
||||
key="test:checkpoint",
|
||||
metadata={
|
||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
||||
"assistant_message": {
|
||||
"role": "assistant",
|
||||
"content": "working",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_done",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "call_pending",
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"completed_tool_results": [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_done",
|
||||
"name": "read_file",
|
||||
"content": "ok",
|
||||
}
|
||||
],
|
||||
"pending_tool_calls": [
|
||||
{
|
||||
"id": "call_pending",
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
restored = loop._restore_runtime_checkpoint(session)
|
||||
|
||||
assert restored is True
|
||||
assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None
|
||||
assert session.messages[0]["role"] == "assistant"
|
||||
assert session.messages[1]["tool_call_id"] == "call_done"
|
||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||
assert "interrupted before this tool finished" in session.messages[2]["content"].lower()
|
||||
|
||||
|
||||
def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(
|
||||
key="test:checkpoint-overlap",
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "working",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_done",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "call_pending",
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_done",
|
||||
"name": "read_file",
|
||||
"content": "ok",
|
||||
},
|
||||
],
|
||||
metadata={
|
||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
||||
"assistant_message": {
|
||||
"role": "assistant",
|
||||
"content": "working",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_done",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "call_pending",
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"completed_tool_results": [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_done",
|
||||
"name": "read_file",
|
||||
"content": "ok",
|
||||
}
|
||||
],
|
||||
"pending_tool_calls": [
|
||||
{
|
||||
"id": "call_pending",
|
||||
"type": "function",
|
||||
"function": {"name": "exec", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
restored = loop._restore_runtime_checkpoint(session)
|
||||
|
||||
assert restored is True
|
||||
assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None
|
||||
assert len(session.messages) == 3
|
||||
assert session.messages[0]["role"] == "assistant"
|
||||
assert session.messages[1]["tool_call_id"] == "call_done"
|
||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""Test MemoryStore.consolidate() handles non-string tool call arguments.
|
||||
|
||||
Regression test for https://github.com/HKUDS/nanobot/issues/1042
|
||||
When memory consolidation receives dict values instead of strings from the LLM
|
||||
tool call response, it should serialize them to JSON instead of raising TypeError.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
def _make_messages(message_count: int = 30):
|
||||
"""Create a list of mock messages."""
|
||||
return [
|
||||
{"role": "user", "content": f"msg{i}", "timestamp": "2026-01-01 00:00"}
|
||||
for i in range(message_count)
|
||||
]
|
||||
|
||||
|
||||
def _make_tool_response(history_entry, memory_update):
|
||||
"""Create an LLMResponse with a save_memory tool call."""
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments={
|
||||
"history_entry": history_entry,
|
||||
"memory_update": memory_update,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class ScriptedProvider(LLMProvider):
|
||||
def __init__(self, responses: list[LLMResponse]):
|
||||
super().__init__()
|
||||
self._responses = list(responses)
|
||||
self.calls = 0
|
||||
|
||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||
self.calls += 1
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return LLMResponse(content="", tool_calls=[])
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
|
||||
class TestMemoryConsolidationTypeHandling:
|
||||
"""Test that consolidation handles various argument types correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_arguments_work(self, tmp_path: Path) -> None:
|
||||
"""Normal case: LLM returns string arguments."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat = AsyncMock(
|
||||
return_value=_make_tool_response(
|
||||
history_entry="[2026-01-01] User discussed testing.",
|
||||
memory_update="# Memory\nUser likes testing.",
|
||||
)
|
||||
)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert store.history_file.exists()
|
||||
assert "[2026-01-01] User discussed testing." in store.history_file.read_text()
|
||||
assert "User likes testing." in store.memory_file.read_text()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_arguments_serialized_to_json(self, tmp_path: Path) -> None:
|
||||
"""Issue #1042: LLM returns dict instead of string — must not raise TypeError."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat = AsyncMock(
|
||||
return_value=_make_tool_response(
|
||||
history_entry={"timestamp": "2026-01-01", "summary": "User discussed testing."},
|
||||
memory_update={"facts": ["User likes testing"], "topics": ["testing"]},
|
||||
)
|
||||
)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert store.history_file.exists()
|
||||
history_content = store.history_file.read_text()
|
||||
parsed = json.loads(history_content.strip())
|
||||
assert parsed["summary"] == "User discussed testing."
|
||||
|
||||
memory_content = store.memory_file.read_text()
|
||||
parsed_mem = json.loads(memory_content)
|
||||
assert "User likes testing" in parsed_mem["facts"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_arguments_as_raw_json(self, tmp_path: Path) -> None:
|
||||
"""Some providers return arguments as a JSON string instead of parsed dict."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
|
||||
response = LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments=json.dumps({
|
||||
"history_entry": "[2026-01-01] User discussed testing.",
|
||||
"memory_update": "# Memory\nUser likes testing.",
|
||||
}),
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.chat = AsyncMock(return_value=response)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert "User discussed testing." in store.history_file.read_text()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tool_call_returns_false(self, tmp_path: Path) -> None:
|
||||
"""When LLM doesn't use the save_memory tool, return False."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat = AsyncMock(
|
||||
return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[])
|
||||
)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_message_chunk_is_empty(self, tmp_path: Path) -> None:
|
||||
"""Consolidation should be a no-op when the selected chunk is empty."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages: list[dict] = []
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
provider.chat.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None:
|
||||
"""Some providers return arguments as a list - extract first element if it's a dict."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
|
||||
response = LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments=[{
|
||||
"history_entry": "[2026-01-01] User discussed testing.",
|
||||
"memory_update": "# Memory\nUser likes testing.",
|
||||
}],
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.chat = AsyncMock(return_value=response)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert "User discussed testing." in store.history_file.read_text()
|
||||
assert "User likes testing." in store.memory_file.read_text()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None:
|
||||
"""Empty list arguments should return False."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
|
||||
response = LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.chat = AsyncMock(return_value=response)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None:
|
||||
"""List with non-dict content should return False."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
|
||||
response = LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments=["string", "content"],
|
||||
)
|
||||
],
|
||||
)
|
||||
provider.chat = AsyncMock(return_value=response)
|
||||
provider.chat_with_retry = provider.chat
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None:
|
||||
"""Do not persist partial results when required fields are missing."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments={"memory_update": "# Memory\nOnly memory update"},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
assert not store.memory_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_memory_update_returns_false_without_writing(self, tmp_path: Path) -> None:
|
||||
"""Do not append history if memory_update is missing."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_1",
|
||||
name="save_memory",
|
||||
arguments={"history_entry": "[2026-01-01] Partial output."},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
assert not store.memory_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_required_field_returns_false_without_writing(self, tmp_path: Path) -> None:
|
||||
"""Null required fields should be rejected before persistence."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=_make_tool_response(
|
||||
history_entry=None,
|
||||
memory_update="# Memory\nUser likes testing.",
|
||||
)
|
||||
)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
assert not store.memory_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None:
|
||||
"""Empty history entries should be rejected to avoid blank archival records."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=_make_tool_response(
|
||||
history_entry=" ",
|
||||
memory_update="# Memory\nUser likes testing.",
|
||||
)
|
||||
)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
assert not store.memory_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_transient_error_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(content="503 server error", finish_reason="error"),
|
||||
_make_tool_response(
|
||||
history_entry="[2026-01-01] User discussed testing.",
|
||||
memory_update="# Memory\nUser likes testing.",
|
||||
),
|
||||
])
|
||||
messages = _make_messages(message_count=60)
|
||||
delays: list[int] = []
|
||||
|
||||
async def _fake_sleep(delay: int) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert provider.calls == 2
|
||||
assert delays == [1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_delegates_to_provider_defaults(self, tmp_path: Path) -> None:
|
||||
"""Consolidation no longer passes generation params — the provider owns them."""
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=_make_tool_response(
|
||||
history_entry="[2026-01-01] User discussed testing.",
|
||||
memory_update="# Memory\nUser likes testing.",
|
||||
)
|
||||
)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
provider.chat_with_retry.assert_awaited_once()
|
||||
_, kwargs = provider.chat_with_retry.await_args
|
||||
assert kwargs["model"] == "test-model"
|
||||
assert "temperature" not in kwargs
|
||||
assert "max_tokens" not in kwargs
|
||||
assert "reasoning_effort" not in kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_choice_fallback_on_unsupported_error(self, tmp_path: Path) -> None:
|
||||
"""Forced tool_choice rejected by provider -> retry with auto and succeed."""
|
||||
store = MemoryStore(tmp_path)
|
||||
error_resp = LLMResponse(
|
||||
content="Error calling LLM: BadRequestError: "
|
||||
"The tool_choice parameter does not support being set to required or object",
|
||||
finish_reason="error",
|
||||
tool_calls=[],
|
||||
)
|
||||
ok_resp = _make_tool_response(
|
||||
history_entry="[2026-01-01] Fallback worked.",
|
||||
memory_update="# Memory\nFallback OK.",
|
||||
)
|
||||
|
||||
call_log: list[dict] = []
|
||||
|
||||
async def _tracking_chat(**kwargs):
|
||||
call_log.append(kwargs)
|
||||
return error_resp if len(call_log) == 1 else ok_resp
|
||||
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=_tracking_chat)
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is True
|
||||
assert len(call_log) == 2
|
||||
assert isinstance(call_log[0]["tool_choice"], dict)
|
||||
assert call_log[1]["tool_choice"] == "auto"
|
||||
assert "Fallback worked." in store.history_file.read_text()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_choice_fallback_auto_no_tool_call(self, tmp_path: Path) -> None:
|
||||
"""Forced rejected, auto retry also produces no tool call -> return False."""
|
||||
store = MemoryStore(tmp_path)
|
||||
error_resp = LLMResponse(
|
||||
content="Error: tool_choice must be none or auto",
|
||||
finish_reason="error",
|
||||
tool_calls=[],
|
||||
)
|
||||
no_tool_resp = LLMResponse(
|
||||
content="Here is a summary.",
|
||||
finish_reason="stop",
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[error_resp, no_tool_resp])
|
||||
messages = _make_messages(message_count=60)
|
||||
|
||||
result = await store.consolidate(messages, provider, "test-model")
|
||||
|
||||
assert result is False
|
||||
assert not store.history_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_archive_after_consecutive_failures(self, tmp_path: Path) -> None:
|
||||
"""After 3 consecutive failures, raw-archive messages and return True."""
|
||||
store = MemoryStore(tmp_path)
|
||||
no_tool = LLMResponse(content="No tool call.", finish_reason="stop", tool_calls=[])
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(return_value=no_tool)
|
||||
messages = _make_messages(message_count=10)
|
||||
|
||||
assert await store.consolidate(messages, provider, "m") is False
|
||||
assert await store.consolidate(messages, provider, "m") is False
|
||||
assert await store.consolidate(messages, provider, "m") is True
|
||||
|
||||
assert store.history_file.exists()
|
||||
content = store.history_file.read_text()
|
||||
assert "[RAW]" in content
|
||||
assert "10 messages" in content
|
||||
assert "msg0" in content
|
||||
assert not store.memory_file.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_archive_counter_resets_on_success(self, tmp_path: Path) -> None:
|
||||
"""A successful consolidation resets the failure counter."""
|
||||
store = MemoryStore(tmp_path)
|
||||
no_tool = LLMResponse(content="Nope.", finish_reason="stop", tool_calls=[])
|
||||
ok_resp = _make_tool_response(
|
||||
history_entry="[2026-01-01] OK.",
|
||||
memory_update="# Memory\nOK.",
|
||||
)
|
||||
messages = _make_messages(message_count=10)
|
||||
|
||||
provider = AsyncMock()
|
||||
provider.chat_with_retry = AsyncMock(return_value=no_tool)
|
||||
assert await store.consolidate(messages, provider, "m") is False
|
||||
assert await store.consolidate(messages, provider, "m") is False
|
||||
assert store._consecutive_failures == 2
|
||||
|
||||
provider.chat_with_retry = AsyncMock(return_value=ok_resp)
|
||||
assert await store.consolidate(messages, provider, "m") is True
|
||||
assert store._consecutive_failures == 0
|
||||
|
||||
provider.chat_with_retry = AsyncMock(return_value=no_tool)
|
||||
assert await store.consolidate(messages, provider, "m") is False
|
||||
assert store._consecutive_failures == 1
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for the restructured MemoryStore — pure file I/O layer."""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return MemoryStore(tmp_path)
|
||||
|
||||
|
||||
class TestMemoryStoreBasicIO:
|
||||
def test_read_memory_returns_empty_when_missing(self, store):
|
||||
assert store.read_memory() == ""
|
||||
|
||||
def test_write_and_read_memory(self, store):
|
||||
store.write_memory("hello")
|
||||
assert store.read_memory() == "hello"
|
||||
|
||||
def test_read_soul_returns_empty_when_missing(self, store):
|
||||
assert store.read_soul() == ""
|
||||
|
||||
def test_write_and_read_soul(self, store):
|
||||
store.write_soul("soul content")
|
||||
assert store.read_soul() == "soul content"
|
||||
|
||||
def test_read_user_returns_empty_when_missing(self, store):
|
||||
assert store.read_user() == ""
|
||||
|
||||
def test_write_and_read_user(self, store):
|
||||
store.write_user("user content")
|
||||
assert store.read_user() == "user content"
|
||||
|
||||
def test_get_memory_context_returns_empty_when_missing(self, store):
|
||||
assert store.get_memory_context() == ""
|
||||
|
||||
def test_get_memory_context_returns_formatted_content(self, store):
|
||||
store.write_memory("important fact")
|
||||
ctx = store.get_memory_context()
|
||||
assert "Long-term Memory" in ctx
|
||||
assert "important fact" in ctx
|
||||
|
||||
|
||||
class TestHistoryWithCursor:
|
||||
def test_append_history_returns_cursor(self, store):
|
||||
cursor = store.append_history("event 1")
|
||||
assert cursor == 1
|
||||
cursor2 = store.append_history("event 2")
|
||||
assert cursor2 == 2
|
||||
|
||||
def test_append_history_includes_cursor_in_file(self, store):
|
||||
store.append_history("event 1")
|
||||
content = store.read_file(store.history_file)
|
||||
data = json.loads(content)
|
||||
assert data["cursor"] == 1
|
||||
|
||||
def test_cursor_persists_across_appends(self, store):
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
cursor = store.append_history("event 3")
|
||||
assert cursor == 3
|
||||
|
||||
def test_read_unprocessed_history(self, store):
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
entries = store.read_unprocessed_history(since_cursor=1)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["cursor"] == 2
|
||||
|
||||
def test_read_unprocessed_history_returns_all_when_cursor_zero(self, store):
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 2
|
||||
|
||||
def test_compact_history_drops_oldest(self, tmp_path):
|
||||
store = MemoryStore(tmp_path, max_history_entries=2)
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
store.append_history("event 4")
|
||||
store.append_history("event 5")
|
||||
store.compact_history()
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["cursor"] in {4, 5}
|
||||
|
||||
|
||||
class TestDreamCursor:
|
||||
def test_initial_cursor_is_zero(self, store):
|
||||
assert store.get_last_dream_cursor() == 0
|
||||
|
||||
def test_set_and_get_cursor(self, store):
|
||||
store.set_last_dream_cursor(5)
|
||||
assert store.get_last_dream_cursor() == 5
|
||||
|
||||
def test_cursor_persists(self, store):
|
||||
store.set_last_dream_cursor(3)
|
||||
store2 = MemoryStore(store.workspace)
|
||||
assert store2.get_last_dream_cursor() == 3
|
||||
|
||||
|
||||
class TestLegacyHistoryMigration:
|
||||
def test_read_unprocessed_history_handles_entries_without_cursor(self, store):
|
||||
"""JSONL entries with cursor=1 are correctly parsed and returned."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-03-30 14:30", "content": "Old event"}\n',
|
||||
encoding="utf-8")
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["cursor"] == 1
|
||||
|
||||
def test_migrates_legacy_history_md_preserving_partial_entries(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_content = (
|
||||
"[2026-04-01 10:00] User prefers dark mode.\n\n"
|
||||
"[2026-04-01 10:05] [RAW] 2 messages\n"
|
||||
"[2026-04-01 10:04] USER: hello\n"
|
||||
"[2026-04-01 10:04] ASSISTANT: hi\n\n"
|
||||
"Legacy chunk without timestamp.\n"
|
||||
"Keep whatever content we can recover.\n"
|
||||
)
|
||||
legacy_file.write_text(legacy_content, encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
fallback_timestamp = datetime.fromtimestamp(
|
||||
(memory_dir / "HISTORY.md.bak").stat().st_mtime,
|
||||
).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [entry["cursor"] for entry in entries] == [1, 2, 3]
|
||||
assert entries[0]["timestamp"] == "2026-04-01 10:00"
|
||||
assert entries[0]["content"] == "User prefers dark mode."
|
||||
assert entries[1]["timestamp"] == "2026-04-01 10:05"
|
||||
assert entries[1]["content"].startswith("[RAW] 2 messages")
|
||||
assert "USER: hello" in entries[1]["content"]
|
||||
assert entries[2]["timestamp"] == fallback_timestamp
|
||||
assert entries[2]["content"].startswith("Legacy chunk without timestamp.")
|
||||
assert store.read_file(store._cursor_file).strip() == "3"
|
||||
assert store.read_file(store._dream_cursor_file).strip() == "3"
|
||||
assert not legacy_file.exists()
|
||||
assert (memory_dir / "HISTORY.md.bak").read_text(encoding="utf-8") == legacy_content
|
||||
|
||||
def test_migrates_consecutive_entries_without_blank_lines(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_content = (
|
||||
"[2026-04-01 10:00] First event.\n"
|
||||
"[2026-04-01 10:01] Second event.\n"
|
||||
"[2026-04-01 10:02] Third event.\n"
|
||||
)
|
||||
legacy_file.write_text(legacy_content, encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 3
|
||||
assert [entry["content"] for entry in entries] == [
|
||||
"First event.",
|
||||
"Second event.",
|
||||
"Third event.",
|
||||
]
|
||||
|
||||
def test_raw_archive_stays_single_entry_while_following_events_split(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_content = (
|
||||
"[2026-04-01 10:05] [RAW] 2 messages\n"
|
||||
"[2026-04-01 10:04] USER: hello\n"
|
||||
"[2026-04-01 10:04] ASSISTANT: hi\n"
|
||||
"[2026-04-01 10:06] Normal event after raw block.\n"
|
||||
)
|
||||
legacy_file.write_text(legacy_content, encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["content"].startswith("[RAW] 2 messages")
|
||||
assert "USER: hello" in entries[0]["content"]
|
||||
assert entries[1]["content"] == "Normal event after raw block."
|
||||
|
||||
def test_nonstandard_date_headers_still_start_new_entries(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_content = (
|
||||
"[2026-03-25–2026-04-02] Multi-day summary.\n"
|
||||
"[2026-03-26/27] Cross-day summary.\n"
|
||||
)
|
||||
legacy_file.write_text(legacy_content, encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
fallback_timestamp = datetime.fromtimestamp(
|
||||
(memory_dir / "HISTORY.md.bak").stat().st_mtime,
|
||||
).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["timestamp"] == fallback_timestamp
|
||||
assert entries[0]["content"] == "[2026-03-25–2026-04-02] Multi-day summary."
|
||||
assert entries[1]["timestamp"] == fallback_timestamp
|
||||
assert entries[1]["content"] == "[2026-03-26/27] Cross-day summary."
|
||||
|
||||
def test_existing_history_jsonl_skips_legacy_migration(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
history_file = memory_dir / "history.jsonl"
|
||||
history_file.write_text(
|
||||
'{"cursor": 7, "timestamp": "2026-04-01 12:00", "content": "existing"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_file.write_text("[2026-04-01 10:00] legacy\n\n", encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["cursor"] == 7
|
||||
assert entries[0]["content"] == "existing"
|
||||
assert legacy_file.exists()
|
||||
assert not (memory_dir / "HISTORY.md.bak").exists()
|
||||
|
||||
def test_empty_history_jsonl_still_allows_legacy_migration(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
history_file = memory_dir / "history.jsonl"
|
||||
history_file.write_text("", encoding="utf-8")
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_file.write_text("[2026-04-01 10:00] legacy\n\n", encoding="utf-8")
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["cursor"] == 1
|
||||
assert entries[0]["timestamp"] == "2026-04-01 10:00"
|
||||
assert entries[0]["content"] == "legacy"
|
||||
assert not legacy_file.exists()
|
||||
assert (memory_dir / "HISTORY.md.bak").exists()
|
||||
|
||||
def test_migrates_legacy_history_with_invalid_utf8_bytes(self, tmp_path):
|
||||
memory_dir = tmp_path / "memory"
|
||||
memory_dir.mkdir()
|
||||
legacy_file = memory_dir / "HISTORY.md"
|
||||
legacy_file.write_bytes(
|
||||
b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n"
|
||||
)
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["timestamp"] == "2026-04-01 10:00"
|
||||
assert "Broken" in entries[0]["content"]
|
||||
assert "migration." in entries[0]["content"]
|
||||
+924
-5
File diff suppressed because it is too large
Load Diff
@@ -173,6 +173,27 @@ def test_empty_session_history():
|
||||
assert history == []
|
||||
|
||||
|
||||
def test_get_history_preserves_reasoning_content():
|
||||
session = Session(key="test:reasoning")
|
||||
session.messages.append({"role": "user", "content": "hi"})
|
||||
session.messages.append({
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden chain of thought",
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden chain of thought",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --- Window cuts mid-group: assistant present but some tool results orphaned ---
|
||||
|
||||
def test_window_cuts_mid_tool_group():
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Tests for nanobot.agent.skills.SkillsLoader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
|
||||
def _write_skill(
|
||||
base: Path,
|
||||
name: str,
|
||||
*,
|
||||
metadata_json: dict | None = None,
|
||||
body: str = "# Skill\n",
|
||||
) -> Path:
|
||||
"""Create ``base / name / SKILL.md`` with optional nanobot metadata JSON."""
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir(parents=True)
|
||||
lines = ["---"]
|
||||
if metadata_json is not None:
|
||||
payload = json.dumps({"nanobot": metadata_json}, separators=(",", ":"))
|
||||
lines.append(f'metadata: {payload}')
|
||||
lines.extend(["---", "", body])
|
||||
path = skill_dir / "SKILL.md"
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_list_skills_empty_when_skills_dir_missing(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
assert loader.list_skills(filter_unavailable=False) == []
|
||||
|
||||
|
||||
def test_list_skills_empty_when_skills_dir_exists_but_empty(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
(workspace / "skills").mkdir(parents=True)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
assert loader.list_skills(filter_unavailable=False) == []
|
||||
|
||||
|
||||
def test_list_skills_workspace_entry_shape_and_source(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
skill_path = _write_skill(skills_root, "alpha", body="# Alpha")
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
assert entries == [
|
||||
{"name": "alpha", "path": str(skill_path), "source": "workspace"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_skills_skips_non_directories_and_missing_skill_md(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
(skills_root / "not_a_dir.txt").write_text("x", encoding="utf-8")
|
||||
(skills_root / "no_skill_md").mkdir()
|
||||
ok_path = _write_skill(skills_root, "ok", body="# Ok")
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
names = {entry["name"] for entry in entries}
|
||||
assert names == {"ok"}
|
||||
assert entries[0]["path"] == str(ok_path)
|
||||
|
||||
|
||||
def test_list_skills_workspace_shadows_builtin_same_name(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
ws_skills = workspace / "skills"
|
||||
ws_skills.mkdir(parents=True)
|
||||
ws_path = _write_skill(ws_skills, "dup", body="# Workspace wins")
|
||||
|
||||
builtin = tmp_path / "builtin"
|
||||
_write_skill(builtin, "dup", body="# Builtin")
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["source"] == "workspace"
|
||||
assert entries[0]["path"] == str(ws_path)
|
||||
|
||||
|
||||
def test_list_skills_merges_workspace_and_builtin(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
ws_skills = workspace / "skills"
|
||||
ws_skills.mkdir(parents=True)
|
||||
ws_path = _write_skill(ws_skills, "ws_only", body="# W")
|
||||
builtin = tmp_path / "builtin"
|
||||
bi_path = _write_skill(builtin, "bi_only", body="# B")
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = sorted(loader.list_skills(filter_unavailable=False), key=lambda item: item["name"])
|
||||
assert entries == [
|
||||
{"name": "bi_only", "path": str(bi_path), "source": "builtin"},
|
||||
{"name": "ws_only", "path": str(ws_path), "source": "workspace"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_skills_builtin_omitted_when_dir_missing(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
ws_skills = workspace / "skills"
|
||||
ws_skills.mkdir(parents=True)
|
||||
ws_path = _write_skill(ws_skills, "solo", body="# S")
|
||||
missing_builtin = tmp_path / "no_such_builtin"
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=missing_builtin)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
assert entries == [{"name": "solo", "path": str(ws_path), "source": "workspace"}]
|
||||
|
||||
|
||||
def test_list_skills_filter_unavailable_excludes_unmet_bin_requirement(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
_write_skill(
|
||||
skills_root,
|
||||
"needs_bin",
|
||||
metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}},
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
def fake_which(cmd: str) -> str | None:
|
||||
if cmd == "nanobot_test_fake_binary":
|
||||
return None
|
||||
return "/usr/bin/true"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.skills.shutil.which", fake_which)
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
assert loader.list_skills(filter_unavailable=True) == []
|
||||
|
||||
|
||||
def test_list_skills_filter_unavailable_includes_when_bin_requirement_met(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
skill_path = _write_skill(
|
||||
skills_root,
|
||||
"has_bin",
|
||||
metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}},
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
def fake_which(cmd: str) -> str | None:
|
||||
if cmd == "nanobot_test_fake_binary":
|
||||
return "/fake/nanobot_test_fake_binary"
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.skills.shutil.which", fake_which)
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = loader.list_skills(filter_unavailable=True)
|
||||
assert entries == [
|
||||
{"name": "has_bin", "path": str(skill_path), "source": "workspace"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_skills_filter_unavailable_false_keeps_unmet_requirements(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
skill_path = _write_skill(
|
||||
skills_root,
|
||||
"blocked",
|
||||
metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}},
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.skills.shutil.which", lambda _cmd: None)
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
assert entries == [
|
||||
{"name": "blocked", "path": str(skill_path), "source": "workspace"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_skills_filter_unavailable_excludes_unmet_env_requirement(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
_write_skill(
|
||||
skills_root,
|
||||
"needs_env",
|
||||
metadata_json={"requires": {"env": ["NANOBOT_SKILLS_TEST_ENV_VAR"]}},
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
monkeypatch.delenv("NANOBOT_SKILLS_TEST_ENV_VAR", raising=False)
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
assert loader.list_skills(filter_unavailable=True) == []
|
||||
|
||||
|
||||
def test_list_skills_openclaw_metadata_parsed_for_requirements(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
skill_dir = skills_root / "openclaw_skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_path = skill_dir / "SKILL.md"
|
||||
oc_payload = json.dumps({"openclaw": {"requires": {"bins": ["nanobot_oc_bin"]}}}, separators=(",", ":"))
|
||||
skill_path.write_text(
|
||||
"\n".join(["---", f"metadata: {oc_payload}", "---", "", "# OC"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.skills.shutil.which", lambda _cmd: None)
|
||||
|
||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin)
|
||||
assert loader.list_skills(filter_unavailable=True) == []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.skills.shutil.which",
|
||||
lambda cmd: "/x" if cmd == "nanobot_oc_bin" else None,
|
||||
)
|
||||
entries = loader.list_skills(filter_unavailable=True)
|
||||
assert entries == [
|
||||
{"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"},
|
||||
]
|
||||
+116
-15
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _make_loop(*, exec_config=None):
|
||||
"""Create a minimal AgentLoop with mocked dependencies."""
|
||||
@@ -116,6 +121,43 @@ class TestDispatch:
|
||||
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
assert out.content == "hi"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_streaming_preserves_message_metadata(self):
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop, bus = _make_loop()
|
||||
msg = InboundMessage(
|
||||
channel="matrix",
|
||||
sender_id="u1",
|
||||
chat_id="!room:matrix.org",
|
||||
content="hello",
|
||||
metadata={
|
||||
"_wants_stream": True,
|
||||
"thread_root_event_id": "$root1",
|
||||
"thread_reply_to_event_id": "$reply1",
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_process(_msg, *, on_stream=None, on_stream_end=None, **kwargs):
|
||||
assert on_stream is not None
|
||||
assert on_stream_end is not None
|
||||
await on_stream("hi")
|
||||
await on_stream_end(resuming=False)
|
||||
return None
|
||||
|
||||
loop._process_message = fake_process
|
||||
|
||||
await loop._dispatch(msg)
|
||||
first = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
second = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
|
||||
assert first.metadata["thread_root_event_id"] == "$root1"
|
||||
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
|
||||
assert first.metadata["_stream_delta"] is True
|
||||
assert second.metadata["thread_root_event_id"] == "$root1"
|
||||
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
|
||||
assert second.metadata["_stream_end"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_lock_serializes(self):
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
@@ -148,7 +190,12 @@ class TestSubagentCancellation:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=MagicMock(),
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
@@ -176,7 +223,12 @@ class TestSubagentCancellation:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=MagicMock(),
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
assert await mgr.cancel_by_session("nonexistent") == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -198,19 +250,24 @@ class TestSubagentCancellation:
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
reasoning_content="hidden reasoning",
|
||||
thinking_blocks=[{"type": "thinking", "thinking": "step"}],
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[])
|
||||
provider.chat_with_retry = scripted_chat_with_retry
|
||||
mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus)
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
|
||||
async def fake_execute(self, name, arguments):
|
||||
async def fake_execute(self, **kwargs):
|
||||
return "tool result"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute)
|
||||
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
||||
|
||||
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
|
||||
|
||||
@@ -222,6 +279,40 @@ class TestSubagentCancellation:
|
||||
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
|
||||
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path):
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
exec_config=ExecToolConfig(enable=False),
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
async def fake_run(spec):
|
||||
assert spec.tools.get("exec") is None
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
error=None,
|
||||
tool_events=[],
|
||||
)
|
||||
|
||||
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
||||
|
||||
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
|
||||
|
||||
mgr.runner.run.assert_awaited_once()
|
||||
mgr._announce_result.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_announces_error_when_tool_execution_fails(self, monkeypatch, tmp_path):
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -233,20 +324,25 @@ class TestSubagentCancellation:
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
))
|
||||
mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus)
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_execute(self, name, arguments):
|
||||
async def fake_execute(self, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return "first result"
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute)
|
||||
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
||||
|
||||
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
|
||||
|
||||
@@ -269,15 +365,20 @@ class TestSubagentCancellation:
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
))
|
||||
mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus)
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def fake_execute(self, name, arguments):
|
||||
async def fake_execute(self, **kwargs):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
@@ -285,7 +386,7 @@ class TestSubagentCancellation:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute)
|
||||
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
||||
|
||||
task = asyncio.create_task(
|
||||
mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
|
||||
@@ -293,7 +394,7 @@ class TestSubagentCancellation:
|
||||
mgr._running_tasks["sub-1"] = task
|
||||
mgr._session_tasks["test:c1"] = {"sub-1"}
|
||||
|
||||
await started.wait()
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
|
||||
count = await mgr.cancel_by_session("test:c1")
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for tool hint formatting (nanobot.utils.tool_hints)."""
|
||||
|
||||
from nanobot.utils.tool_hints import format_tool_hints
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
|
||||
|
||||
def _tc(name: str, args) -> ToolCallRequest:
|
||||
return ToolCallRequest(id="c1", name=name, arguments=args)
|
||||
|
||||
|
||||
def _hint(calls):
|
||||
"""Shortcut for format_tool_hints."""
|
||||
return format_tool_hints(calls)
|
||||
|
||||
|
||||
class TestToolHintKnownTools:
|
||||
"""Test registered tool types produce correct formatted output."""
|
||||
|
||||
def test_read_file_short_path(self):
|
||||
result = _hint([_tc("read_file", {"path": "foo.txt"})])
|
||||
assert result == 'read foo.txt'
|
||||
|
||||
def test_read_file_long_path(self):
|
||||
result = _hint([_tc("read_file", {"path": "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"})])
|
||||
assert "loop.py" in result
|
||||
assert "read " in result
|
||||
|
||||
def test_write_file_shows_path_not_content(self):
|
||||
result = _hint([_tc("write_file", {"path": "docs/api.md", "content": "# API Reference\n\nLong content..."})])
|
||||
assert result == "write docs/api.md"
|
||||
|
||||
def test_edit_shows_path(self):
|
||||
result = _hint([_tc("edit", {"file_path": "src/main.py", "old_string": "x", "new_string": "y"})])
|
||||
assert "main.py" in result
|
||||
assert "edit " in result
|
||||
|
||||
def test_glob_shows_pattern(self):
|
||||
result = _hint([_tc("glob", {"pattern": "**/*.py", "path": "src"})])
|
||||
assert result == 'glob "**/*.py"'
|
||||
|
||||
def test_grep_shows_pattern(self):
|
||||
result = _hint([_tc("grep", {"pattern": "TODO|FIXME", "path": "src"})])
|
||||
assert result == 'grep "TODO|FIXME"'
|
||||
|
||||
def test_exec_shows_command(self):
|
||||
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
||||
assert result == "$ npm install typescript"
|
||||
|
||||
def test_exec_truncates_long_command(self):
|
||||
cmd = "cd /very/long/path && cat file && echo done && sleep 1 && ls -la"
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert result.startswith("$ ")
|
||||
assert len(result) <= 50 # reasonable limit
|
||||
|
||||
def test_exec_abbreviates_paths_in_command(self):
|
||||
"""Windows paths in exec commands should be folded, not blindly truncated."""
|
||||
cmd = "cd D:\\Documents\\GitHub\\nanobot\\.worktree\\tomain\\nanobot && git diff origin/main...pr-2706 --name-only 2>&1"
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result # path should be folded with …/
|
||||
assert "worktree" not in result # middle segments should be collapsed
|
||||
|
||||
def test_exec_abbreviates_linux_paths(self):
|
||||
"""Unix absolute paths in exec commands should be folded."""
|
||||
cmd = "cd /home/user/projects/nanobot/.worktree/tomain && make build"
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result
|
||||
assert "projects" not in result
|
||||
|
||||
def test_exec_abbreviates_home_paths(self):
|
||||
"""~/ paths in exec commands should be folded."""
|
||||
cmd = "cd ~/projects/nanobot/workspace && pytest tests/"
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result
|
||||
|
||||
def test_exec_abbreviates_quoted_linux_paths_with_spaces(self):
|
||||
"""Quoted Unix paths with spaces should still be folded."""
|
||||
cmd = 'cd "/home/user/My Documents/project" && pytest tests/'
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result
|
||||
assert '"/home/user/My Documents/project"' not in result
|
||||
assert '"' in result
|
||||
|
||||
def test_exec_abbreviates_quoted_windows_paths_with_spaces(self):
|
||||
"""Quoted Windows paths with spaces should still be folded."""
|
||||
cmd = 'cd "C:/Program Files/Git/project" && git status'
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result
|
||||
assert '"C:/Program Files/Git/project"' not in result
|
||||
assert '"' in result
|
||||
|
||||
def test_exec_short_command_unchanged(self):
|
||||
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
||||
assert result == "$ npm install typescript"
|
||||
|
||||
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||
result = _hint([_tc("exec", {"command": cmd})])
|
||||
assert "\u2026/" in result # path folded
|
||||
assert "npm" in result # chained command still visible
|
||||
|
||||
def test_web_search(self):
|
||||
result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})])
|
||||
assert result == 'search "Claude 4 vs GPT-4"'
|
||||
|
||||
def test_web_fetch(self):
|
||||
result = _hint([_tc("web_fetch", {"url": "https://example.com/page"})])
|
||||
assert result == "fetch https://example.com/page"
|
||||
|
||||
|
||||
class TestToolHintMCP:
|
||||
"""Test MCP tools are abbreviated to server::tool format."""
|
||||
|
||||
def test_mcp_standard_format(self):
|
||||
result = _hint([_tc("mcp_4_5v_mcp__analyze_image", {"imageSource": "https://img.jpg", "prompt": "describe"})])
|
||||
assert "4_5v" in result
|
||||
assert "analyze_image" in result
|
||||
|
||||
def test_mcp_simple_name(self):
|
||||
result = _hint([_tc("mcp_github__create_issue", {"title": "Bug fix"})])
|
||||
assert "github" in result
|
||||
assert "create_issue" in result
|
||||
|
||||
|
||||
class TestToolHintFallback:
|
||||
"""Test unknown tools fall back to original behavior."""
|
||||
|
||||
def test_unknown_tool_with_string_arg(self):
|
||||
result = _hint([_tc("custom_tool", {"data": "hello world"})])
|
||||
assert result == 'custom_tool("hello world")'
|
||||
|
||||
def test_unknown_tool_with_long_arg_truncates(self):
|
||||
long_val = "a" * 60
|
||||
result = _hint([_tc("custom_tool", {"data": long_val})])
|
||||
assert len(result) < 80
|
||||
assert "\u2026" in result
|
||||
|
||||
def test_unknown_tool_no_string_arg(self):
|
||||
result = _hint([_tc("custom_tool", {"count": 42})])
|
||||
assert result == "custom_tool"
|
||||
|
||||
def test_empty_tool_calls(self):
|
||||
result = _hint([])
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestToolHintFolding:
|
||||
"""Test consecutive same-tool calls are folded."""
|
||||
|
||||
def test_single_call_no_fold(self):
|
||||
calls = [_tc("grep", {"pattern": "*.py"})]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
|
||||
def test_two_consecutive_different_args_not_folded(self):
|
||||
calls = [
|
||||
_tc("grep", {"pattern": "*.py"}),
|
||||
_tc("grep", {"pattern": "*.ts"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
|
||||
def test_two_consecutive_same_args_folded(self):
|
||||
calls = [
|
||||
_tc("grep", {"pattern": "TODO"}),
|
||||
_tc("grep", {"pattern": "TODO"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7 2" in result
|
||||
|
||||
def test_three_consecutive_different_args_not_folded(self):
|
||||
calls = [
|
||||
_tc("read_file", {"path": "a.py"}),
|
||||
_tc("read_file", {"path": "b.py"}),
|
||||
_tc("read_file", {"path": "c.py"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
|
||||
def test_different_tools_not_folded(self):
|
||||
calls = [
|
||||
_tc("grep", {"pattern": "TODO"}),
|
||||
_tc("read_file", {"path": "a.py"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
|
||||
def test_interleaved_same_tools_not_folded(self):
|
||||
calls = [
|
||||
_tc("grep", {"pattern": "a"}),
|
||||
_tc("read_file", {"path": "f.py"}),
|
||||
_tc("grep", {"pattern": "b"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
|
||||
|
||||
class TestToolHintMultipleCalls:
|
||||
"""Test multiple different tool calls are comma-separated."""
|
||||
|
||||
def test_two_different_tools(self):
|
||||
calls = [
|
||||
_tc("grep", {"pattern": "TODO"}),
|
||||
_tc("read_file", {"path": "main.py"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert 'grep "TODO"' in result
|
||||
assert "read main.py" in result
|
||||
assert ", " in result
|
||||
|
||||
|
||||
class TestToolHintEdgeCases:
|
||||
"""Test edge cases and defensive handling (G1, G2)."""
|
||||
|
||||
def test_known_tool_empty_list_args(self):
|
||||
"""C1/G1: Empty list arguments should not crash."""
|
||||
result = _hint([_tc("read_file", [])])
|
||||
assert result == "read_file"
|
||||
|
||||
def test_known_tool_none_args(self):
|
||||
"""G2: None arguments should not crash."""
|
||||
result = _hint([_tc("read_file", None)])
|
||||
assert result == "read_file"
|
||||
|
||||
def test_fallback_empty_list_args(self):
|
||||
"""C1: Empty list args in fallback should not crash."""
|
||||
result = _hint([_tc("custom_tool", [])])
|
||||
assert result == "custom_tool"
|
||||
|
||||
def test_fallback_none_args(self):
|
||||
"""G2: None args in fallback should not crash."""
|
||||
result = _hint([_tc("custom_tool", None)])
|
||||
assert result == "custom_tool"
|
||||
|
||||
def test_list_dir_registered(self):
|
||||
"""S2: list_dir should use 'ls' format."""
|
||||
result = _hint([_tc("list_dir", {"path": "/tmp"})])
|
||||
assert result == "ls /tmp"
|
||||
|
||||
|
||||
class TestToolHintMixedFolding:
|
||||
"""G4: Mixed folding groups with interleaved same-tool segments."""
|
||||
|
||||
def test_read_read_grep_grep_read(self):
|
||||
"""All different args — each hint listed separately."""
|
||||
calls = [
|
||||
_tc("read_file", {"path": "a.py"}),
|
||||
_tc("read_file", {"path": "b.py"}),
|
||||
_tc("grep", {"pattern": "x"}),
|
||||
_tc("grep", {"pattern": "y"}),
|
||||
_tc("read_file", {"path": "c.py"}),
|
||||
]
|
||||
result = _hint(calls)
|
||||
assert "\u00d7" not in result
|
||||
parts = result.split(", ")
|
||||
assert len(parts) == 5
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Tests for unified_session feature.
|
||||
|
||||
Covers:
|
||||
- AgentLoop._dispatch() rewrites session_key to "unified:default" when enabled
|
||||
- Existing session_key_override is respected (not overwritten)
|
||||
- Feature is off by default (no behavior change for existing users)
|
||||
- Config schema serialises unified_session as camelCase "unifiedSession"
|
||||
- onboard-generated config.json contains "unifiedSession" key
|
||||
- /new command correctly clears the shared session in unified mode
|
||||
- /new is NOT a priority command (goes through _dispatch, key rewrite applies)
|
||||
- Context window consolidation is unaffected by unified_session
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command.builtin import cmd_new, register_builtin_commands
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.config.schema import AgentDefaults, Config
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
|
||||
"""Create a minimal AgentLoop for dispatch-level tests."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
with patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr, \
|
||||
patch("nanobot.agent.loop.Dream"):
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
return loop
|
||||
|
||||
|
||||
def _make_msg(channel: str = "telegram", chat_id: str = "111",
|
||||
session_key_override: str | None = None) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
sender_id="user1",
|
||||
content="hello",
|
||||
session_key_override=session_key_override,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestUnifiedSessionDispatch — core behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnifiedSessionDispatch:
|
||||
"""AgentLoop._dispatch() session key rewriting logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_session_rewrites_key_to_unified_default(self, tmp_path: Path):
|
||||
"""When unified_session=True, all messages use 'unified:default' as session key."""
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def fake_process(msg, **kwargs):
|
||||
captured.append(msg.session_key)
|
||||
return None
|
||||
|
||||
loop._process_message = fake_process # type: ignore[method-assign]
|
||||
|
||||
msg = _make_msg(channel="telegram", chat_id="111")
|
||||
await loop._dispatch(msg)
|
||||
|
||||
assert captured == ["unified:default"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_session_different_channels_share_same_key(self, tmp_path: Path):
|
||||
"""Messages from different channels all resolve to the same session key."""
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def fake_process(msg, **kwargs):
|
||||
captured.append(msg.session_key)
|
||||
return None
|
||||
|
||||
loop._process_message = fake_process # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(_make_msg(channel="telegram", chat_id="111"))
|
||||
await loop._dispatch(_make_msg(channel="discord", chat_id="222"))
|
||||
await loop._dispatch(_make_msg(channel="cli", chat_id="direct"))
|
||||
|
||||
assert captured == ["unified:default", "unified:default", "unified:default"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_session_disabled_preserves_original_key(self, tmp_path: Path):
|
||||
"""When unified_session=False (default), session key is channel:chat_id as usual."""
|
||||
loop = _make_loop(tmp_path, unified_session=False)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def fake_process(msg, **kwargs):
|
||||
captured.append(msg.session_key)
|
||||
return None
|
||||
|
||||
loop._process_message = fake_process # type: ignore[method-assign]
|
||||
|
||||
msg = _make_msg(channel="telegram", chat_id="999")
|
||||
await loop._dispatch(msg)
|
||||
|
||||
assert captured == ["telegram:999"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_session_respects_existing_override(self, tmp_path: Path):
|
||||
"""If session_key_override is already set (e.g. Telegram thread), it is NOT overwritten."""
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
async def fake_process(msg, **kwargs):
|
||||
captured.append(msg.session_key)
|
||||
return None
|
||||
|
||||
loop._process_message = fake_process # type: ignore[method-assign]
|
||||
|
||||
msg = _make_msg(channel="telegram", chat_id="111", session_key_override="telegram:thread:42")
|
||||
await loop._dispatch(msg)
|
||||
|
||||
assert captured == ["telegram:thread:42"]
|
||||
|
||||
def test_unified_session_default_is_false(self, tmp_path: Path):
|
||||
"""unified_session defaults to False — no behavior change for existing users."""
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop._unified_session is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestUnifiedSessionConfig — schema & serialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnifiedSessionConfig:
|
||||
"""Config schema and onboard serialisation for unified_session."""
|
||||
|
||||
def test_agent_defaults_unified_session_default_is_false(self):
|
||||
"""AgentDefaults.unified_session defaults to False."""
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.unified_session is False
|
||||
|
||||
def test_agent_defaults_unified_session_can_be_enabled(self):
|
||||
"""AgentDefaults.unified_session can be set to True."""
|
||||
defaults = AgentDefaults(unified_session=True)
|
||||
assert defaults.unified_session is True
|
||||
|
||||
def test_config_serialises_unified_session_as_camel_case(self):
|
||||
"""model_dump(by_alias=True) outputs 'unifiedSession' (camelCase) for JSON."""
|
||||
config = Config()
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
agents_defaults = data["agents"]["defaults"]
|
||||
assert "unifiedSession" in agents_defaults
|
||||
assert agents_defaults["unifiedSession"] is False
|
||||
|
||||
def test_config_parses_unified_session_from_camel_case(self):
|
||||
"""Config can be loaded from JSON with camelCase 'unifiedSession'."""
|
||||
raw = {"agents": {"defaults": {"unifiedSession": True}}}
|
||||
config = Config.model_validate(raw)
|
||||
assert config.agents.defaults.unified_session is True
|
||||
|
||||
def test_config_parses_unified_session_from_snake_case(self):
|
||||
"""Config also accepts snake_case 'unified_session' (populate_by_name=True)."""
|
||||
raw = {"agents": {"defaults": {"unified_session": True}}}
|
||||
config = Config.model_validate(raw)
|
||||
assert config.agents.defaults.unified_session is True
|
||||
|
||||
def test_onboard_generated_config_contains_unified_session(self, tmp_path: Path):
|
||||
"""save_config() writes 'unifiedSession' into config.json (simulates nanobot onboard)."""
|
||||
from nanobot.config.loader import save_config
|
||||
|
||||
config = Config()
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(config, config_path)
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
agents_defaults = data["agents"]["defaults"]
|
||||
assert "unifiedSession" in agents_defaults, (
|
||||
"onboard-generated config.json must contain 'unifiedSession' key"
|
||||
)
|
||||
assert agents_defaults["unifiedSession"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdNewUnifiedSession — /new command behaviour in unified mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdNewUnifiedSession:
|
||||
"""/new command routing and session-clear behaviour in unified mode."""
|
||||
|
||||
def test_new_is_not_a_priority_command(self):
|
||||
"""/new must NOT be in the priority table — it must go through _dispatch()
|
||||
so the unified session key rewrite applies before cmd_new runs."""
|
||||
router = CommandRouter()
|
||||
register_builtin_commands(router)
|
||||
assert router.is_priority("/new") is False
|
||||
|
||||
def test_new_is_an_exact_command(self):
|
||||
"""/new must be registered as an exact command."""
|
||||
router = CommandRouter()
|
||||
register_builtin_commands(router)
|
||||
assert "/new" in router._exact
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_new_clears_unified_session(self, tmp_path: Path):
|
||||
"""cmd_new called with key='unified:default' clears the shared session."""
|
||||
sessions = SessionManager(tmp_path)
|
||||
|
||||
# Pre-populate the shared session with some messages
|
||||
shared = sessions.get_or_create("unified:default")
|
||||
shared.add_message("user", "hello from telegram")
|
||||
shared.add_message("assistant", "hi there")
|
||||
sessions.save(shared)
|
||||
assert len(sessions.get_or_create("unified:default").messages) == 2
|
||||
|
||||
# _schedule_background is a *sync* method that schedules a coroutine via
|
||||
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
|
||||
# and no RuntimeWarning is emitted.
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
)
|
||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
||||
session_key_override="unified:default", # as _dispatch() would set it
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
|
||||
|
||||
result = await cmd_new(ctx)
|
||||
|
||||
assert "New session started" in result.content
|
||||
# Invalidate cache and reload from disk to confirm persistence
|
||||
sessions.invalidate("unified:default")
|
||||
reloaded = sessions.get_or_create("unified:default")
|
||||
assert reloaded.messages == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_new_in_unified_mode_does_not_affect_other_sessions(self, tmp_path: Path):
|
||||
"""Clearing unified:default must not touch other sessions on disk."""
|
||||
sessions = SessionManager(tmp_path)
|
||||
|
||||
other = sessions.get_or_create("discord:999")
|
||||
other.add_message("user", "discord message")
|
||||
sessions.save(other)
|
||||
|
||||
shared = sessions.get_or_create("unified:default")
|
||||
shared.add_message("user", "shared message")
|
||||
sessions.save(shared)
|
||||
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
)
|
||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
||||
session_key_override="unified:default",
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
|
||||
await cmd_new(ctx)
|
||||
|
||||
sessions.invalidate("unified:default")
|
||||
sessions.invalidate("discord:999")
|
||||
assert sessions.get_or_create("unified:default").messages == []
|
||||
assert len(sessions.get_or_create("discord:999").messages) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestConsolidationUnaffectedByUnifiedSession — consolidation is key-agnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsolidationUnaffectedByUnifiedSession:
|
||||
"""maybe_consolidate_by_tokens() behaviour is identical regardless of session key."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_skips_empty_session_for_unified_key(self):
|
||||
"""Empty unified:default session → consolidation exits immediately, archive not called."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
||||
# Use spec= so MagicMock doesn't auto-generate AsyncMock for non-async methods,
|
||||
# which would leave unawaited coroutines and trigger RuntimeWarning.
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
provider=mock_provider,
|
||||
model="test-model",
|
||||
sessions=sessions,
|
||||
context_window_tokens=1000,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
max_completion_tokens=100,
|
||||
)
|
||||
consolidator.archive = AsyncMock()
|
||||
|
||||
session = Session(key="unified:default")
|
||||
session.messages = []
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
consolidator.archive.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_behaviour_identical_for_any_key(self):
|
||||
"""archive call count is the same for 'telegram:123' and 'unified:default'
|
||||
under identical token conditions."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
archive_calls: dict[str, int] = {}
|
||||
|
||||
for key in ("telegram:123", "unified:default"):
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
provider=mock_provider,
|
||||
model="test-model",
|
||||
sessions=sessions,
|
||||
context_window_tokens=1000,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
max_completion_tokens=100,
|
||||
)
|
||||
|
||||
session = Session(key=key)
|
||||
session.messages = [] # empty → exits immediately for both keys
|
||||
|
||||
consolidator.archive = AsyncMock()
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
archive_calls[key] = consolidator.archive.call_count
|
||||
|
||||
assert archive_calls["telegram:123"] == archive_calls["unified:default"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_triggers_when_over_budget_unified_key(self):
|
||||
"""When tokens exceed budget, consolidation attempts to find a boundary —
|
||||
behaviour is identical to any other session key."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
provider=mock_provider,
|
||||
model="test-model",
|
||||
sessions=sessions,
|
||||
context_window_tokens=1000,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
max_completion_tokens=100,
|
||||
)
|
||||
|
||||
session = Session(key="unified:default")
|
||||
session.messages = [{"role": "user", "content": "msg"}]
|
||||
|
||||
# Simulate over-budget: estimated > budget
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
|
||||
# No valid boundary found → returns gracefully without archiving
|
||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=None)
|
||||
consolidator.archive = AsyncMock()
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
# estimate was called (consolidation was attempted)
|
||||
consolidator.estimate_session_prompt_tokens.assert_called_once_with(session)
|
||||
# but archive was not called (no valid boundary)
|
||||
consolidator.archive.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestStopCommandWithUnifiedSession — /stop command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopCommandWithUnifiedSession:
|
||||
"""Verify /stop command works correctly with unified session enabled."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path):
|
||||
"""When unified_session=True, tasks are stored under UNIFIED_SESSION_KEY."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
# Create a message from telegram channel
|
||||
msg = _make_msg(channel="telegram", chat_id="123456")
|
||||
|
||||
# Mock _dispatch to complete immediately
|
||||
async def fake_dispatch(m):
|
||||
pass
|
||||
|
||||
loop._dispatch = fake_dispatch # type: ignore[method-assign]
|
||||
|
||||
# Simulate the task creation flow (from _run loop)
|
||||
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
|
||||
task = asyncio.create_task(loop._dispatch(msg))
|
||||
loop._active_tasks.setdefault(effective_key, []).append(task)
|
||||
|
||||
# Wait for task to complete
|
||||
await task
|
||||
|
||||
# Verify the task is stored under UNIFIED_SESSION_KEY, not the original channel:chat_id
|
||||
assert UNIFIED_SESSION_KEY in loop._active_tasks
|
||||
assert "telegram:123456" not in loop._active_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
|
||||
"""cmd_stop can cancel tasks when unified_session=True."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
# Create a long-running task stored under UNIFIED_SESSION_KEY
|
||||
async def long_running():
|
||||
await asyncio.sleep(10) # Will be cancelled
|
||||
|
||||
task = asyncio.create_task(long_running())
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
||||
|
||||
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
|
||||
msg = InboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123456",
|
||||
sender_id="user1",
|
||||
content="/stop",
|
||||
session_key_override=UNIFIED_SESSION_KEY, # Simulate post-dispatch state
|
||||
)
|
||||
|
||||
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
|
||||
|
||||
# Execute /stop
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
# Verify task was cancelled
|
||||
assert task.cancelled() or task.done()
|
||||
assert "Stopped 1 task" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path):
|
||||
"""In unified mode, /stop from one channel cancels tasks from another channel."""
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
|
||||
loop = _make_loop(tmp_path, unified_session=True)
|
||||
|
||||
# Create tasks from different channels, all stored under UNIFIED_SESSION_KEY
|
||||
async def long_running():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
task1 = asyncio.create_task(long_running())
|
||||
task2 = asyncio.create_task(long_running())
|
||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task1, task2]
|
||||
|
||||
# /stop from discord should cancel tasks started from telegram
|
||||
msg = InboundMessage(
|
||||
channel="discord",
|
||||
chat_id="789012",
|
||||
sender_id="user2",
|
||||
content="/stop",
|
||||
session_key_override=UNIFIED_SESSION_KEY,
|
||||
)
|
||||
|
||||
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
|
||||
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
# Both tasks should be cancelled
|
||||
assert "Stopped 2 task" in result.content
|
||||
@@ -13,6 +13,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.schema import ChannelsConfig
|
||||
from nanobot.utils.restart import RestartNotice
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -208,7 +209,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
||||
seen["config"] = self.config
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: Config())
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
lambda: {"fakeplugin": _LoginPlugin},
|
||||
@@ -220,6 +221,57 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
||||
assert seen["force"] is True
|
||||
|
||||
|
||||
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
seen: dict[str, object] = {}
|
||||
config_path = tmp_path / "custom-config.json"
|
||||
|
||||
class _LoginPlugin(_FakePlugin):
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
lambda path: seen.__setitem__("config_path", path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.registry.discover_all",
|
||||
lambda: {"fakeplugin": _LoginPlugin},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["channels", "login", "fakeplugin", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["config_path"] == config_path.resolve()
|
||||
|
||||
|
||||
def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
seen: dict[str, object] = {}
|
||||
config_path = tmp_path / "custom-config.json"
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
lambda path: seen.__setitem__("config_path", path),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
|
||||
|
||||
result = runner.invoke(app, ["channels", "status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["config_path"] == config_path.resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_skips_disabled_plugin():
|
||||
fake_config = SimpleNamespace(
|
||||
@@ -878,3 +930,30 @@ async def test_start_all_creates_dispatch_task():
|
||||
# Dispatch task should have been created
|
||||
assert mgr._dispatch_task is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_restart_done_enqueues_outbound_message():
|
||||
"""Restart notice should schedule send_with_retry for target channel."""
|
||||
fake_config = SimpleNamespace(
|
||||
channels=ChannelsConfig(),
|
||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||
)
|
||||
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
mgr.config = fake_config
|
||||
mgr.bus = MessageBus()
|
||||
mgr.channels = {"feishu": _StartableChannel(fake_config, mgr.bus)}
|
||||
mgr._dispatch_task = None
|
||||
mgr._send_with_retry = AsyncMock()
|
||||
|
||||
notice = RestartNotice(channel="feishu", chat_id="oc_123", started_at_raw="100.0")
|
||||
with patch("nanobot.channels.manager.consume_restart_notice_from_env", return_value=notice):
|
||||
mgr._notify_restart_done_if_needed()
|
||||
|
||||
await asyncio.sleep(0)
|
||||
mgr._send_with_retry.assert_awaited_once()
|
||||
sent_channel, sent_msg = mgr._send_with_retry.await_args.args
|
||||
assert sent_channel is mgr.channels["feishu"]
|
||||
assert sent_msg.channel == "feishu"
|
||||
assert sent_msg.chat_id == "oc_123"
|
||||
assert sent_msg.content.startswith("Restart completed")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -221,3 +223,78 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
assert "messageFiles/download" in channel._http.calls[0]["url"]
|
||||
assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
|
||||
assert channel._http.calls[1]["method"] == "GET"
|
||||
|
||||
|
||||
def test_normalize_upload_payload_zips_html_attachment() -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
data, filename, content_type = channel._normalize_upload_payload(
|
||||
"report.html",
|
||||
b"<html><body>Hello</body></html>",
|
||||
"text/html",
|
||||
)
|
||||
|
||||
assert filename == "report.zip"
|
||||
assert content_type == "application/zip"
|
||||
|
||||
archive = zipfile.ZipFile(BytesIO(data))
|
||||
assert archive.namelist() == ["report.html"]
|
||||
assert archive.read("report.html") == b"<html><body>Hello</body></html>"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
html_path = tmp_path / "report.html"
|
||||
html_path.write_text("<html><body>Hello</body></html>", encoding="utf-8")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_upload_media(*, token, data, media_type, filename, content_type):
|
||||
captured.update(
|
||||
{
|
||||
"token": token,
|
||||
"data": data,
|
||||
"media_type": media_type,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
}
|
||||
)
|
||||
return "media-123"
|
||||
|
||||
async def fake_send_batch_message(token, chat_id, msg_key, msg_param):
|
||||
captured.update(
|
||||
{
|
||||
"sent_token": token,
|
||||
"chat_id": chat_id,
|
||||
"msg_key": msg_key,
|
||||
"msg_param": msg_param,
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(channel, "_upload_media", fake_upload_media)
|
||||
monkeypatch.setattr(channel, "_send_batch_message", fake_send_batch_message)
|
||||
|
||||
ok = await channel._send_media_ref("token-123", "user-1", str(html_path))
|
||||
|
||||
assert ok is True
|
||||
assert captured["media_type"] == "file"
|
||||
assert captured["filename"] == "report.zip"
|
||||
assert captured["content_type"] == "application/zip"
|
||||
assert captured["msg_key"] == "sampleFile"
|
||||
assert captured["msg_param"] == {
|
||||
"mediaId": "media-123",
|
||||
"fileName": "report.zip",
|
||||
"fileType": "zip",
|
||||
}
|
||||
|
||||
archive = zipfile.ZipFile(BytesIO(captured["data"]))
|
||||
assert archive.namelist() == ["report.html"]
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
discord = pytest.importorskip("discord")
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.discord import MAX_MESSAGE_LEN, DiscordBotClient, DiscordChannel, DiscordConfig
|
||||
from nanobot.command.builtin import build_help_text
|
||||
|
||||
|
||||
# Minimal Discord client test double used to control startup/readiness behavior.
|
||||
class _FakeDiscordClient:
|
||||
instances: list["_FakeDiscordClient"] = []
|
||||
start_error: Exception | None = None
|
||||
|
||||
def __init__(self, owner, *, intents) -> None:
|
||||
self.owner = owner
|
||||
self.intents = intents
|
||||
self.closed = False
|
||||
self.ready = True
|
||||
self.channels: dict[int, object] = {}
|
||||
self.user = SimpleNamespace(id=999)
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
async def start(self, token: str) -> None:
|
||||
self.token = token
|
||||
if self.__class__.start_error is not None:
|
||||
raise self.__class__.start_error
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self.closed
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return self.ready
|
||||
|
||||
def get_channel(self, channel_id: int):
|
||||
return self.channels.get(channel_id)
|
||||
|
||||
async def send_outbound(self, msg: OutboundMessage) -> None:
|
||||
channel = self.get_channel(int(msg.chat_id))
|
||||
if channel is None:
|
||||
return
|
||||
await channel.send(content=msg.content)
|
||||
|
||||
|
||||
class _FakeAttachment:
|
||||
# Attachment double that can simulate successful or failing save() calls.
|
||||
def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None:
|
||||
self.id = attachment_id
|
||||
self.filename = filename
|
||||
self.size = size
|
||||
self._fail = fail
|
||||
|
||||
async def save(self, path: str | Path) -> None:
|
||||
if self._fail:
|
||||
raise RuntimeError("save failed")
|
||||
Path(path).write_bytes(b"attachment")
|
||||
|
||||
|
||||
class _FakePartialMessage:
|
||||
# Lightweight stand-in for Discord partial message references used in replies.
|
||||
def __init__(self, message_id: int) -> None:
|
||||
self.id = message_id
|
||||
|
||||
|
||||
class _FakeSentMessage:
|
||||
# Sent-message double supporting edit() for streaming tests.
|
||||
def __init__(self, channel, content: str) -> None:
|
||||
self.channel = channel
|
||||
self.content = content
|
||||
self.edits: list[dict] = []
|
||||
|
||||
async def edit(self, **kwargs) -> None:
|
||||
self.edits.append(dict(kwargs))
|
||||
if "content" in kwargs:
|
||||
self.content = kwargs["content"]
|
||||
|
||||
|
||||
class _FakeChannel:
|
||||
# Channel double that records outbound payloads and typing activity.
|
||||
def __init__(self, channel_id: int = 123) -> None:
|
||||
self.id = channel_id
|
||||
self.sent_payloads: list[dict] = []
|
||||
self.sent_messages: list[_FakeSentMessage] = []
|
||||
self.trigger_typing_calls = 0
|
||||
self.typing_enter_hook = None
|
||||
|
||||
async def send(self, **kwargs) -> None:
|
||||
payload = dict(kwargs)
|
||||
if "file" in payload:
|
||||
payload["file_name"] = payload["file"].filename
|
||||
del payload["file"]
|
||||
self.sent_payloads.append(payload)
|
||||
message = _FakeSentMessage(self, payload.get("content", ""))
|
||||
self.sent_messages.append(message)
|
||||
return message
|
||||
|
||||
def get_partial_message(self, message_id: int) -> _FakePartialMessage:
|
||||
return _FakePartialMessage(message_id)
|
||||
|
||||
def typing(self):
|
||||
channel = self
|
||||
|
||||
class _TypingContext:
|
||||
async def __aenter__(self):
|
||||
channel.trigger_typing_calls += 1
|
||||
if channel.typing_enter_hook is not None:
|
||||
await channel.typing_enter_hook()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
return _TypingContext()
|
||||
|
||||
|
||||
class _FakeInteractionResponse:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[dict] = []
|
||||
self._done = False
|
||||
|
||||
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
|
||||
self.messages.append({"content": content, "ephemeral": ephemeral})
|
||||
self._done = True
|
||||
|
||||
def is_done(self) -> bool:
|
||||
return self._done
|
||||
|
||||
|
||||
def _make_interaction(
|
||||
*,
|
||||
user_id: int = 123,
|
||||
channel_id: int | None = 456,
|
||||
guild_id: int | None = None,
|
||||
interaction_id: int = 999,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
user=SimpleNamespace(id=user_id),
|
||||
channel_id=channel_id,
|
||||
guild_id=guild_id,
|
||||
id=interaction_id,
|
||||
command=SimpleNamespace(qualified_name="new"),
|
||||
response=_FakeInteractionResponse(),
|
||||
)
|
||||
|
||||
|
||||
def _make_message(
|
||||
*,
|
||||
author_id: int = 123,
|
||||
author_bot: bool = False,
|
||||
channel_id: int = 456,
|
||||
message_id: int = 789,
|
||||
content: str = "hello",
|
||||
guild_id: int | None = None,
|
||||
mentions: list[object] | None = None,
|
||||
attachments: list[object] | None = None,
|
||||
reply_to: int | None = None,
|
||||
):
|
||||
# Factory for incoming Discord message objects with optional guild/reply/attachments.
|
||||
guild = SimpleNamespace(id=guild_id) if guild_id is not None else None
|
||||
reference = SimpleNamespace(message_id=reply_to) if reply_to is not None else None
|
||||
return SimpleNamespace(
|
||||
author=SimpleNamespace(id=author_id, bot=author_bot),
|
||||
channel=_FakeChannel(channel_id),
|
||||
content=content,
|
||||
guild=guild,
|
||||
mentions=mentions or [],
|
||||
attachments=attachments or [],
|
||||
reference=reference,
|
||||
id=message_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_when_token_missing() -> None:
|
||||
# If no token is configured, startup should no-op and leave channel stopped.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert channel.is_running is False
|
||||
assert channel._client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False)
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert channel.is_running is False
|
||||
assert channel._client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_handles_client_construction_failure(monkeypatch) -> None:
|
||||
# Construction errors from the Discord client should be swallowed and keep state clean.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
def _boom(owner, *, intents):
|
||||
raise RuntimeError("bad client")
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert channel.is_running is False
|
||||
assert channel._client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_handles_client_start_failure(monkeypatch) -> None:
|
||||
# If client.start fails, the partially created client should be closed and detached.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
_FakeDiscordClient.instances.clear()
|
||||
_FakeDiscordClient.start_error = RuntimeError("connect failed")
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert channel.is_running is False
|
||||
assert channel._client is None
|
||||
assert _FakeDiscordClient.instances[0].intents.value == channel.config.intents
|
||||
assert _FakeDiscordClient.instances[0].closed is True
|
||||
|
||||
_FakeDiscordClient.start_error = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_is_safe_after_partial_start(monkeypatch) -> None:
|
||||
# stop() should close/discard the client even when startup was only partially completed.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
client = _FakeDiscordClient(channel, intents=None)
|
||||
channel._client = client
|
||||
channel._running = True
|
||||
|
||||
await channel.stop()
|
||||
|
||||
assert channel.is_running is False
|
||||
assert client.closed is True
|
||||
assert channel._client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_ignores_bot_messages() -> None:
|
||||
# Incoming bot-authored messages must be ignored to prevent feedback loops.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(_make_message(author_bot=True))
|
||||
|
||||
assert handled == []
|
||||
|
||||
# If inbound handling raises, typing should be stopped for that channel.
|
||||
async def fail_handle(**kwargs) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
channel._handle_message = fail_handle # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await channel._on_message(_make_message(author_id=123, channel_id=456))
|
||||
|
||||
assert channel._typing_tasks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_accepts_allowlisted_dm() -> None:
|
||||
# Allowed direct messages should be forwarded with normalized metadata.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(_make_message(author_id=123, channel_id=456, message_id=789))
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["chat_id"] == "456"
|
||||
assert handled[0]["metadata"] == {"message_id": "789", "guild_id": None, "reply_to": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_ignores_unmentioned_guild_message() -> None:
|
||||
# With mention-only group policy, guild messages without a bot mention are dropped.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._bot_user_id = "999"
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(_make_message(guild_id=1, content="hello everyone"))
|
||||
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_accepts_mentioned_guild_message() -> None:
|
||||
# Mentioned guild messages should be accepted and preserve reply threading metadata.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._bot_user_id = "999"
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
guild_id=1,
|
||||
content="<@999> hello",
|
||||
mentions=[SimpleNamespace(id=999)],
|
||||
reply_to=321,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["metadata"]["reply_to"] == "321"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None:
|
||||
# Attachment downloads should be saved and referenced in forwarded content/media.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
attachments=[_FakeAttachment(12, "photo.png")],
|
||||
content="see file",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["media"] == [str(tmp_path / "12_photo.png")]
|
||||
assert "[attachment:" in handled[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch) -> None:
|
||||
# Failed attachment downloads should emit a readable placeholder and no media path.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
attachments=[_FakeAttachment(12, "photo.png", fail=True)],
|
||||
content="",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["media"] == []
|
||||
assert handled[0]["content"] == "[attachment: photo.png - download failed]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_warns_when_client_not_ready() -> None:
|
||||
# Sending without a running/ready client should be a safe no-op.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
|
||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
||||
|
||||
assert channel._typing_tasks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_skips_when_channel_not_cached() -> None:
|
||||
# Outbound sends should be skipped when the destination channel is not resolvable.
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||
fetch_calls: list[int] = []
|
||||
|
||||
async def fetch_channel(channel_id: int):
|
||||
fetch_calls.append(channel_id)
|
||||
raise RuntimeError("not found")
|
||||
|
||||
client.fetch_channel = fetch_channel # type: ignore[method-assign]
|
||||
|
||||
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
||||
|
||||
assert client.get_channel(123) is None
|
||||
assert fetch_calls == [123]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_fetches_channel_when_not_cached() -> None:
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||
target = _FakeChannel(channel_id=123)
|
||||
|
||||
async def fetch_channel(channel_id: int):
|
||||
return target if channel_id == 123 else None
|
||||
|
||||
client.fetch_channel = fetch_channel # type: ignore[method-assign]
|
||||
|
||||
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
||||
|
||||
assert target.sent_payloads == [{"content": "hello"}]
|
||||
|
||||
|
||||
def test_supports_streaming_enabled_by_default() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
|
||||
assert channel.supports_streaming is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = _FakeDiscordClient(owner, intents=None)
|
||||
owner._client = client
|
||||
owner._running = True
|
||||
target = _FakeChannel(channel_id=123)
|
||||
client.channels[123] = target
|
||||
|
||||
times = iter([1.0, 3.0, 5.0])
|
||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
|
||||
|
||||
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
||||
|
||||
assert target.sent_payloads[0] == {"content": "hel"}
|
||||
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
|
||||
assert owner._stream_bufs == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None:
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = _FakeDiscordClient(owner, intents=None)
|
||||
owner._client = client
|
||||
owner._running = True
|
||||
target = _FakeChannel(channel_id=123)
|
||||
client.channels[123] = target
|
||||
|
||||
prefix = "a" * (MAX_MESSAGE_LEN - 100)
|
||||
suffix = "b" * 150
|
||||
full_text = prefix + suffix
|
||||
chunks = DiscordBotClient._build_chunks(full_text, [], False)
|
||||
assert len(chunks) == 2
|
||||
|
||||
times = iter([1.0, 3.0])
|
||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
|
||||
|
||||
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
|
||||
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
||||
|
||||
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
|
||||
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
|
||||
assert owner._stream_bufs == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction(user_id=123, channel_id=456, interaction_id=321)
|
||||
|
||||
new_cmd = client.tree.get_command("new")
|
||||
assert new_cmd is not None
|
||||
await new_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": "Processing /new...", "ephemeral": True}
|
||||
]
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == "/new"
|
||||
assert handled[0]["sender_id"] == "123"
|
||||
assert handled[0]["chat_id"] == "456"
|
||||
assert handled[0]["metadata"]["interaction_id"] == "321"
|
||||
assert handled[0]["metadata"]["is_slash_command"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_new_is_blocked_for_disallowed_user() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["999"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction(user_id=123, channel_id=456)
|
||||
|
||||
new_cmd = client.tree.get_command("new")
|
||||
assert new_cmd is not None
|
||||
await new_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": "You are not allowed to use this bot.", "ephemeral": True}
|
||||
]
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction()
|
||||
interaction.command.qualified_name = slash_name
|
||||
|
||||
cmd = client.tree.get_command(slash_name)
|
||||
assert cmd is not None
|
||||
await cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": f"Processing /{slash_name}...", "ephemeral": True}
|
||||
]
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == f"/{slash_name}"
|
||||
assert handled[0]["metadata"]["is_slash_command"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_help_returns_ephemeral_help_text() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction()
|
||||
interaction.command.qualified_name = "help"
|
||||
|
||||
help_cmd = client.tree.get_command("help")
|
||||
assert help_cmd is not None
|
||||
await help_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": build_help_text(), "ephemeral": True}
|
||||
]
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_send_outbound_chunks_text_replies_and_uploads_files(tmp_path) -> None:
|
||||
# Outbound payloads should upload files, attach reply references, and chunk long text.
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||
target = _FakeChannel(channel_id=123)
|
||||
client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign]
|
||||
|
||||
file_path = tmp_path / "demo.txt"
|
||||
file_path.write_text("hi")
|
||||
|
||||
await client.send_outbound(
|
||||
OutboundMessage(
|
||||
channel="discord",
|
||||
chat_id="123",
|
||||
content="a" * 2100,
|
||||
reply_to="55",
|
||||
media=[str(file_path)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(target.sent_payloads) == 3
|
||||
assert target.sent_payloads[0]["file_name"] == "demo.txt"
|
||||
assert target.sent_payloads[0]["reference"].id == 55
|
||||
assert target.sent_payloads[1]["content"] == "a" * 2000
|
||||
assert target.sent_payloads[2]["content"] == "a" * 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_send_outbound_reports_failed_attachments_when_no_text(tmp_path) -> None:
|
||||
# If all attachment sends fail and no text exists, emit a failure placeholder message.
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||
target = _FakeChannel(channel_id=123)
|
||||
client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign]
|
||||
|
||||
missing_file = tmp_path / "missing.txt"
|
||||
|
||||
await client.send_outbound(
|
||||
OutboundMessage(
|
||||
channel="discord",
|
||||
chat_id="123",
|
||||
content="",
|
||||
media=[str(missing_file)],
|
||||
)
|
||||
)
|
||||
|
||||
assert target.sent_payloads == [{"content": "[attachment: missing.txt - send failed]"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stops_typing_after_send() -> None:
|
||||
# Active typing indicators should be cancelled/cleared after a successful send.
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = _FakeDiscordClient(channel, intents=None)
|
||||
channel._client = client
|
||||
channel._running = True
|
||||
|
||||
start = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def slow_typing() -> None:
|
||||
start.set()
|
||||
await release.wait()
|
||||
|
||||
typing_channel = _FakeChannel(channel_id=123)
|
||||
typing_channel.typing_enter_hook = slow_typing
|
||||
|
||||
await channel._start_typing(typing_channel)
|
||||
await asyncio.wait_for(start.wait(), timeout=1.0)
|
||||
|
||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
||||
release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert channel._typing_tasks == {}
|
||||
|
||||
# Progress messages should keep typing active until a final (non-progress) send.
|
||||
start = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def slow_typing_progress() -> None:
|
||||
start.set()
|
||||
await release.wait()
|
||||
|
||||
typing_channel = _FakeChannel(channel_id=123)
|
||||
typing_channel.typing_enter_hook = slow_typing_progress
|
||||
|
||||
await channel._start_typing(typing_channel)
|
||||
await asyncio.wait_for(start.wait(), timeout=1.0)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="discord",
|
||||
chat_id="123",
|
||||
content="progress",
|
||||
metadata={"_progress": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "123" in channel._typing_tasks
|
||||
|
||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="final"))
|
||||
release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert channel._typing_tasks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_typing_uses_typing_context_when_trigger_typing_missing() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
channel._running = True
|
||||
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
class _TypingCtx:
|
||||
async def __aenter__(self):
|
||||
entered.set()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class _NoTriggerChannel:
|
||||
def __init__(self, channel_id: int = 123) -> None:
|
||||
self.id = channel_id
|
||||
|
||||
def typing(self):
|
||||
async def _waiter():
|
||||
await release.wait()
|
||||
# Hold the loop so task remains active until explicitly stopped.
|
||||
class _Ctx(_TypingCtx):
|
||||
async def __aenter__(self):
|
||||
await super().__aenter__()
|
||||
await _waiter()
|
||||
return _Ctx()
|
||||
|
||||
typing_channel = _NoTriggerChannel(channel_id=123)
|
||||
await channel._start_typing(typing_channel) # type: ignore[arg-type]
|
||||
await asyncio.wait_for(entered.wait(), timeout=1.0)
|
||||
|
||||
assert "123" in channel._typing_tasks
|
||||
|
||||
await channel._stop_typing("123")
|
||||
release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert channel._typing_tasks == {}
|
||||
@@ -1,5 +1,6 @@
|
||||
from email.message import EmailMessage
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import imaplib
|
||||
|
||||
import pytest
|
||||
@@ -650,3 +651,224 @@ def test_check_authentication_results_method() -> None:
|
||||
spf, dkim = EmailChannel._check_authentication_results(parsed)
|
||||
assert spf is False
|
||||
assert dkim is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachment extraction tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_raw_email_with_attachment(
|
||||
from_addr: str = "alice@example.com",
|
||||
subject: str = "With attachment",
|
||||
body: str = "See attached.",
|
||||
attachment_name: str = "doc.pdf",
|
||||
attachment_content: bytes = b"%PDF-1.4 fake pdf content",
|
||||
attachment_mime: str = "application/pdf",
|
||||
auth_results: str | None = None,
|
||||
) -> bytes:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = "bot@example.com"
|
||||
msg["Subject"] = subject
|
||||
msg["Message-ID"] = "<m1@example.com>"
|
||||
if auth_results:
|
||||
msg["Authentication-Results"] = auth_results
|
||||
msg.set_content(body)
|
||||
maintype, subtype = attachment_mime.split("/", 1)
|
||||
msg.add_attachment(
|
||||
attachment_content,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=attachment_name,
|
||||
)
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||
"""PDF attachment is saved to media dir and path returned in media list."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment()
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(allowed_attachment_types=["application/pdf"], verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert len(items[0]["media"]) == 1
|
||||
saved_path = Path(items[0]["media"][0])
|
||||
assert saved_path.exists()
|
||||
assert saved_path.read_bytes() == b"%PDF-1.4 fake pdf content"
|
||||
assert "500_doc.pdf" in saved_path.name
|
||||
assert "[attachment:" in items[0]["content"]
|
||||
|
||||
|
||||
def test_extract_attachments_disabled_by_default(monkeypatch) -> None:
|
||||
"""With no allowed_attachment_types (default), no attachments are extracted."""
|
||||
raw = _make_raw_email_with_attachment()
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||
assert cfg.allowed_attachment_types == []
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["media"] == []
|
||||
assert "[attachment:" not in items[0]["content"]
|
||||
|
||||
|
||||
def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
||||
"""Non-allowed MIME types are skipped."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="image.png",
|
||||
attachment_content=b"\x89PNG fake",
|
||||
attachment_mime="image/png",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["application/pdf"],
|
||||
verify_dkim=False,
|
||||
verify_spf=False,
|
||||
)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["media"] == []
|
||||
|
||||
|
||||
def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypatch) -> None:
|
||||
"""Empty allowed_attachment_types means no types are accepted."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="image.png",
|
||||
attachment_content=b"\x89PNG fake",
|
||||
attachment_mime="image/png",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=[],
|
||||
verify_dkim=False,
|
||||
verify_spf=False,
|
||||
)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["media"] == []
|
||||
|
||||
|
||||
def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
||||
"""Glob patterns like 'image/*' match attachment MIME types."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="photo.jpg",
|
||||
attachment_content=b"\xff\xd8\xff fake jpeg",
|
||||
attachment_mime="image/jpeg",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["image/*"],
|
||||
verify_dkim=False,
|
||||
verify_spf=False,
|
||||
)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert len(items[0]["media"]) == 1
|
||||
|
||||
|
||||
def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
|
||||
"""Attachments exceeding max_attachment_size are skipped."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_content=b"x" * 1000,
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["*"],
|
||||
max_attachment_size=500,
|
||||
verify_dkim=False,
|
||||
verify_spf=False,
|
||||
)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["media"] == []
|
||||
|
||||
|
||||
def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
||||
"""Only max_attachments_per_email are saved."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
# Build email with 3 attachments
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "alice@example.com"
|
||||
msg["To"] = "bot@example.com"
|
||||
msg["Subject"] = "Many attachments"
|
||||
msg["Message-ID"] = "<m1@example.com>"
|
||||
msg.set_content("See attached.")
|
||||
for i in range(3):
|
||||
msg.add_attachment(
|
||||
f"content {i}".encode(),
|
||||
maintype="application",
|
||||
subtype="pdf",
|
||||
filename=f"doc{i}.pdf",
|
||||
)
|
||||
raw = msg.as_bytes()
|
||||
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["*"],
|
||||
max_attachments_per_email=2,
|
||||
verify_dkim=False,
|
||||
verify_spf=False,
|
||||
)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert len(items[0]["media"]) == 2
|
||||
|
||||
|
||||
def test_extract_attachments_sanitizes_filename(tmp_path, monkeypatch) -> None:
|
||||
"""Path traversal in filenames is neutralized."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="../../../etc/passwd",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(allowed_attachment_types=["*"], verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
items = channel._fetch_new_messages()
|
||||
|
||||
assert len(items) == 1
|
||||
assert len(items[0]["media"]) == 1
|
||||
saved_path = Path(items[0]["media"][0])
|
||||
# File must be inside the media dir, not escaped via path traversal
|
||||
assert saved_path.parent == tmp_path
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for Feishu _is_bot_mentioned logic."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
|
||||
def _make_channel(bot_open_id: str | None = None) -> FeishuChannel:
|
||||
config = SimpleNamespace(
|
||||
app_id="test_id",
|
||||
app_secret="test_secret",
|
||||
verification_token="",
|
||||
event_encrypt_key="",
|
||||
group_policy="mention",
|
||||
)
|
||||
ch = FeishuChannel.__new__(FeishuChannel)
|
||||
ch.config = config
|
||||
ch._bot_open_id = bot_open_id
|
||||
return ch
|
||||
|
||||
|
||||
def _make_message(mentions=None, content="hello"):
|
||||
return SimpleNamespace(content=content, mentions=mentions)
|
||||
|
||||
|
||||
def _make_mention(open_id: str, user_id: str | None = None):
|
||||
mid = SimpleNamespace(open_id=open_id, user_id=user_id)
|
||||
return SimpleNamespace(id=mid)
|
||||
|
||||
|
||||
class TestIsBotMentioned:
|
||||
def test_exact_match_with_bot_open_id(self):
|
||||
ch = _make_channel(bot_open_id="ou_bot123")
|
||||
msg = _make_message(mentions=[_make_mention("ou_bot123")])
|
||||
assert ch._is_bot_mentioned(msg) is True
|
||||
|
||||
def test_no_match_different_bot(self):
|
||||
ch = _make_channel(bot_open_id="ou_bot123")
|
||||
msg = _make_message(mentions=[_make_mention("ou_other_bot")])
|
||||
assert ch._is_bot_mentioned(msg) is False
|
||||
|
||||
def test_at_all_always_matches(self):
|
||||
ch = _make_channel(bot_open_id="ou_bot123")
|
||||
msg = _make_message(content="@_all hello")
|
||||
assert ch._is_bot_mentioned(msg) is True
|
||||
|
||||
def test_fallback_heuristic_when_no_bot_open_id(self):
|
||||
ch = _make_channel(bot_open_id=None)
|
||||
msg = _make_message(mentions=[_make_mention("ou_some_bot", user_id=None)])
|
||||
assert ch._is_bot_mentioned(msg) is True
|
||||
|
||||
def test_fallback_ignores_user_mentions(self):
|
||||
ch = _make_channel(bot_open_id=None)
|
||||
msg = _make_message(mentions=[_make_mention("ou_user", user_id="u_12345")])
|
||||
assert ch._is_bot_mentioned(msg) is False
|
||||
|
||||
def test_no_mentions_returns_false(self):
|
||||
ch = _make_channel(bot_open_id="ou_bot123")
|
||||
msg = _make_message(mentions=None)
|
||||
assert ch._is_bot_mentioned(msg) is False
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for FeishuChannel._resolve_mentions."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
|
||||
def _mention(key: str, name: str, open_id: str = "", user_id: str = ""):
|
||||
"""Build a mock MentionEvent-like object."""
|
||||
id_obj = SimpleNamespace(open_id=open_id, user_id=user_id) if (open_id or user_id) else None
|
||||
return SimpleNamespace(key=key, name=name, id=id_obj)
|
||||
|
||||
|
||||
class TestResolveMentions:
|
||||
def test_single_mention_replaced(self):
|
||||
text = "hello @_user_1 how are you"
|
||||
mentions = [_mention("@_user_1", "Alice", open_id="ou_abc123")]
|
||||
result = FeishuChannel._resolve_mentions(text, mentions)
|
||||
assert "@Alice (ou_abc123)" in result
|
||||
assert "@_user_1" not in result
|
||||
|
||||
def test_mention_with_both_ids(self):
|
||||
text = "@_user_1 said hi"
|
||||
mentions = [_mention("@_user_1", "Bob", open_id="ou_abc", user_id="uid_456")]
|
||||
result = FeishuChannel._resolve_mentions(text, mentions)
|
||||
assert "@Bob (ou_abc, user id: uid_456)" in result
|
||||
|
||||
def test_mention_no_id_skipped(self):
|
||||
"""When mention has no id object, the placeholder is left unchanged."""
|
||||
text = "@_user_1 said hi"
|
||||
mentions = [SimpleNamespace(key="@_user_1", name="Charlie", id=None)]
|
||||
result = FeishuChannel._resolve_mentions(text, mentions)
|
||||
assert result == "@_user_1 said hi"
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
text = "@_user_1 and @_user_2 are here"
|
||||
mentions = [
|
||||
_mention("@_user_1", "Alice", open_id="ou_a"),
|
||||
_mention("@_user_2", "Bob", open_id="ou_b"),
|
||||
]
|
||||
result = FeishuChannel._resolve_mentions(text, mentions)
|
||||
assert "@Alice (ou_a)" in result
|
||||
assert "@Bob (ou_b)" in result
|
||||
assert "@_user_1" not in result
|
||||
assert "@_user_2" not in result
|
||||
|
||||
def test_no_mentions_returns_text(self):
|
||||
assert FeishuChannel._resolve_mentions("hello world", None) == "hello world"
|
||||
assert FeishuChannel._resolve_mentions("hello world", []) == "hello world"
|
||||
|
||||
def test_empty_text_returns_empty(self):
|
||||
mentions = [_mention("@_user_1", "Alice", open_id="ou_a")]
|
||||
assert FeishuChannel._resolve_mentions("", mentions) == ""
|
||||
|
||||
def test_mention_key_not_in_text_skipped(self):
|
||||
text = "hello world"
|
||||
mentions = [_mention("@_user_99", "Ghost", open_id="ou_ghost")]
|
||||
result = FeishuChannel._resolve_mentions(text, mentions)
|
||||
assert result == "hello world"
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
|
||||
|
||||
def _make_channel() -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
app_secret="secret",
|
||||
allow_from=["*"],
|
||||
)
|
||||
ch = FeishuChannel(config, MessageBus())
|
||||
ch._client = MagicMock()
|
||||
ch._loop = None
|
||||
return ch
|
||||
|
||||
|
||||
def _mock_reaction_create_response(reaction_id: str = "reaction_001", success: bool = True):
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = success
|
||||
resp.code = 0 if success else 99999
|
||||
resp.msg = "ok" if success else "error"
|
||||
if success:
|
||||
resp.data = SimpleNamespace(reaction_id=reaction_id)
|
||||
else:
|
||||
resp.data = None
|
||||
return resp
|
||||
|
||||
|
||||
# ── _add_reaction_sync ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAddReactionSync:
|
||||
def test_returns_reaction_id_on_success(self):
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response("rx_42")
|
||||
result = ch._add_reaction_sync("om_001", "THUMBSUP")
|
||||
assert result == "rx_42"
|
||||
|
||||
def test_returns_none_when_response_fails(self):
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response(success=False)
|
||||
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
|
||||
|
||||
def test_returns_none_when_response_data_is_none(self):
|
||||
ch = _make_channel()
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = True
|
||||
resp.data = None
|
||||
ch._client.im.v1.message_reaction.create.return_value = resp
|
||||
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
|
||||
|
||||
def test_returns_none_on_exception(self):
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message_reaction.create.side_effect = RuntimeError("network error")
|
||||
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
|
||||
|
||||
|
||||
# ── _add_reaction (async) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAddReactionAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_reaction_id(self):
|
||||
ch = _make_channel()
|
||||
ch._add_reaction_sync = MagicMock(return_value="rx_99")
|
||||
result = await ch._add_reaction("om_001", "EYES")
|
||||
assert result == "rx_99"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_client(self):
|
||||
ch = _make_channel()
|
||||
ch._client = None
|
||||
result = await ch._add_reaction("om_001", "THUMBSUP")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _remove_reaction_sync ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRemoveReactionSync:
|
||||
def test_calls_delete_on_success(self):
|
||||
ch = _make_channel()
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = True
|
||||
ch._client.im.v1.message_reaction.delete.return_value = resp
|
||||
|
||||
ch._remove_reaction_sync("om_001", "rx_42")
|
||||
|
||||
ch._client.im.v1.message_reaction.delete.assert_called_once()
|
||||
|
||||
def test_handles_failure_gracefully(self):
|
||||
ch = _make_channel()
|
||||
resp = MagicMock()
|
||||
resp.success.return_value = False
|
||||
resp.code = 99999
|
||||
resp.msg = "not found"
|
||||
ch._client.im.v1.message_reaction.delete.return_value = resp
|
||||
|
||||
# Should not raise
|
||||
ch._remove_reaction_sync("om_001", "rx_42")
|
||||
|
||||
def test_handles_exception_gracefully(self):
|
||||
ch = _make_channel()
|
||||
ch._client.im.v1.message_reaction.delete.side_effect = RuntimeError("network error")
|
||||
|
||||
# Should not raise
|
||||
ch._remove_reaction_sync("om_001", "rx_42")
|
||||
|
||||
|
||||
# ── _remove_reaction (async) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRemoveReactionAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_sync_helper(self):
|
||||
ch = _make_channel()
|
||||
ch._remove_reaction_sync = MagicMock()
|
||||
|
||||
await ch._remove_reaction("om_001", "rx_42")
|
||||
|
||||
ch._remove_reaction_sync.assert_called_once_with("om_001", "rx_42")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_no_client(self):
|
||||
ch = _make_channel()
|
||||
ch._client = None
|
||||
ch._remove_reaction_sync = MagicMock()
|
||||
|
||||
await ch._remove_reaction("om_001", "rx_42")
|
||||
|
||||
ch._remove_reaction_sync.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_reaction_id_is_empty(self):
|
||||
ch = _make_channel()
|
||||
ch._remove_reaction_sync = MagicMock()
|
||||
|
||||
await ch._remove_reaction("om_001", "")
|
||||
|
||||
ch._remove_reaction_sync.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_reaction_id_is_none(self):
|
||||
ch = _make_channel()
|
||||
ch._remove_reaction_sync = MagicMock()
|
||||
|
||||
await ch._remove_reaction("om_001", None)
|
||||
|
||||
ch._remove_reaction_sync.assert_not_called()
|
||||
|
||||
|
||||
# ── send_delta stream end: reaction auto-cleanup ────────────────────────────
|
||||
|
||||
|
||||
class TestStreamEndReactionCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_reaction_on_stream_end(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_message_id_missing(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "reaction_id": "rx_42"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_reaction_id_missing(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_both_ids_missing(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_removal_when_not_stream_end(self):
|
||||
ch = _make_channel()
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "more text",
|
||||
metadata={"message_id": "om_001", "reaction_id": "rx_42"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
@@ -127,6 +127,79 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_tool_hint_new_format_basic(mock_feishu_channel):
|
||||
"""New format hints (read path, grep "pattern") should parse correctly."""
|
||||
msg = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_123456",
|
||||
content='read src/main.py, grep "TODO"',
|
||||
metadata={"_tool_hint": True}
|
||||
)
|
||||
|
||||
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||
await mock_feishu_channel.send(msg)
|
||||
|
||||
content = json.loads(mock_send.call_args[0][3])
|
||||
md = content["elements"][0]["content"]
|
||||
assert "read src/main.py" in md
|
||||
assert 'grep "TODO"' in md
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
|
||||
"""Commas inside quoted arguments must not cause incorrect line splits."""
|
||||
msg = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_123456",
|
||||
content='grep "hello, world", $ echo test',
|
||||
metadata={"_tool_hint": True}
|
||||
)
|
||||
|
||||
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||
await mock_feishu_channel.send(msg)
|
||||
|
||||
content = json.loads(mock_send.call_args[0][3])
|
||||
md = content["elements"][0]["content"]
|
||||
# The comma inside quotes should NOT cause a line break
|
||||
assert 'grep "hello, world"' in md
|
||||
assert "$ echo test" in md
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
|
||||
"""Folded calls (× N) should display on separate lines."""
|
||||
msg = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_123456",
|
||||
content='read path × 3, grep "pattern"',
|
||||
metadata={"_tool_hint": True}
|
||||
)
|
||||
|
||||
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||
await mock_feishu_channel.send(msg)
|
||||
|
||||
content = json.loads(mock_send.call_args[0][3])
|
||||
md = content["elements"][0]["content"]
|
||||
assert "\u00d7 3" in md
|
||||
assert 'grep "pattern"' in md
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_tool_hint_new_format_mcp(mock_feishu_channel):
|
||||
"""MCP tool format (server::tool) should parse correctly."""
|
||||
msg = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_123456",
|
||||
content='4_5v::analyze_image("photo.jpg")',
|
||||
metadata={"_tool_hint": True}
|
||||
)
|
||||
|
||||
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||
await mock_feishu_channel.send(msg)
|
||||
|
||||
content = json.loads(mock_send.call_args[0][3])
|
||||
md = content["elements"][0]["content"]
|
||||
assert "4_5v::analyze_image" in md
|
||||
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
|
||||
"""Commas inside a single tool argument must not be split onto a new line."""
|
||||
msg = OutboundMessage(
|
||||
|
||||
@@ -4,11 +4,12 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
# Check optional matrix dependencies before importing
|
||||
try:
|
||||
import nh3 # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("Matrix dependencies not installed (nh3)", allow_module_level=True)
|
||||
pytest.importorskip("nio")
|
||||
pytest.importorskip("nh3")
|
||||
pytest.importorskip("mistune")
|
||||
from nio import RoomSendResponse
|
||||
|
||||
from nanobot.channels.matrix import _build_matrix_text_content
|
||||
|
||||
import nanobot.channels.matrix as matrix_module
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -65,6 +66,7 @@ class _FakeAsyncClient:
|
||||
self.raise_on_send = False
|
||||
self.raise_on_typing = False
|
||||
self.raise_on_upload = False
|
||||
self.room_send_response: RoomSendResponse | None = RoomSendResponse(event_id="", room_id="")
|
||||
|
||||
def add_event_callback(self, callback, event_type) -> None:
|
||||
self.callbacks.append((callback, event_type))
|
||||
@@ -87,7 +89,7 @@ class _FakeAsyncClient:
|
||||
message_type: str,
|
||||
content: dict[str, object],
|
||||
ignore_unverified_devices: object = _ROOM_SEND_UNSET,
|
||||
) -> None:
|
||||
) -> RoomSendResponse:
|
||||
call: dict[str, object] = {
|
||||
"room_id": room_id,
|
||||
"message_type": message_type,
|
||||
@@ -98,6 +100,7 @@ class _FakeAsyncClient:
|
||||
self.room_send_calls.append(call)
|
||||
if self.raise_on_send:
|
||||
raise RuntimeError("send failed")
|
||||
return self.room_send_response
|
||||
|
||||
async def room_typing(
|
||||
self,
|
||||
@@ -520,6 +523,7 @@ async def test_on_message_room_mention_requires_opt_in() -> None:
|
||||
source={"content": {"m.mentions": {"room": True}}},
|
||||
)
|
||||
|
||||
channel.config.allow_room_mentions = False
|
||||
await channel._on_message(room, room_mention_event)
|
||||
assert handled == []
|
||||
assert client.typing_calls == []
|
||||
@@ -1322,3 +1326,302 @@ async def test_send_keeps_plaintext_only_for_plain_text() -> None:
|
||||
"body": text,
|
||||
"m.mentions": {},
|
||||
}
|
||||
|
||||
|
||||
def test_build_matrix_text_content_basic_text() -> None:
|
||||
"""Test basic text content without HTML formatting."""
|
||||
result = _build_matrix_text_content("Hello, World!")
|
||||
expected = {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello, World!",
|
||||
"m.mentions": {}
|
||||
}
|
||||
assert expected == result
|
||||
|
||||
|
||||
def test_build_matrix_text_content_with_markdown() -> None:
|
||||
"""Test text content with markdown that renders to HTML."""
|
||||
text = "*Hello* **World**"
|
||||
result = _build_matrix_text_content(text)
|
||||
assert "msgtype" in result
|
||||
assert "body" in result
|
||||
assert result["body"] == text
|
||||
assert "format" in result
|
||||
assert result["format"] == "org.matrix.custom.html"
|
||||
assert "formatted_body" in result
|
||||
assert isinstance(result["formatted_body"], str)
|
||||
assert len(result["formatted_body"]) > 0
|
||||
|
||||
|
||||
def test_build_matrix_text_content_with_event_id() -> None:
|
||||
"""Test text content with event_id for message replacement."""
|
||||
event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
result = _build_matrix_text_content("Updated message", event_id)
|
||||
assert "msgtype" in result
|
||||
assert "body" in result
|
||||
assert result["m.new_content"]
|
||||
assert result["m.new_content"]["body"] == "Updated message"
|
||||
assert result["m.relates_to"]["rel_type"] == "m.replace"
|
||||
assert result["m.relates_to"]["event_id"] == event_id
|
||||
|
||||
|
||||
def test_build_matrix_text_content_with_event_id_preserves_thread_relation() -> None:
|
||||
"""Thread relations for edits should stay inside m.new_content."""
|
||||
relates_to = {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$root1",
|
||||
"m.in_reply_to": {"event_id": "$reply1"},
|
||||
"is_falling_back": True,
|
||||
}
|
||||
result = _build_matrix_text_content("Updated message", "event-1", relates_to)
|
||||
|
||||
assert result["m.relates_to"] == {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "event-1",
|
||||
}
|
||||
assert result["m.new_content"]["m.relates_to"] == relates_to
|
||||
|
||||
|
||||
def test_build_matrix_text_content_no_event_id() -> None:
|
||||
"""Test that when event_id is not provided, no extra properties are added."""
|
||||
result = _build_matrix_text_content("Regular message")
|
||||
|
||||
# Basic required properties should be present
|
||||
assert "msgtype" in result
|
||||
assert "body" in result
|
||||
assert result["body"] == "Regular message"
|
||||
|
||||
# Extra properties for replacement should NOT be present
|
||||
assert "m.relates_to" not in result
|
||||
assert "m.new_content" not in result
|
||||
assert "format" not in result
|
||||
assert "formatted_body" not in result
|
||||
|
||||
|
||||
def test_build_matrix_text_content_plain_text_no_html() -> None:
|
||||
"""Test plain text that should not include HTML formatting."""
|
||||
result = _build_matrix_text_content("Simple plain text")
|
||||
assert "msgtype" in result
|
||||
assert "body" in result
|
||||
assert "format" not in result
|
||||
assert "formatted_body" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_room_content_returns_room_send_response():
|
||||
"""Test that _send_room_content returns the response from client.room_send."""
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
channel.client = client
|
||||
|
||||
room_id = "!test_room:matrix.org"
|
||||
content = {"msgtype": "m.text", "body": "Hello World"}
|
||||
|
||||
result = await channel._send_room_content(room_id, content)
|
||||
|
||||
assert result is client.room_send_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_creates_stream_buffer_and_sends_initial_message() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "Hello")
|
||||
|
||||
assert "!room:matrix.org" in channel._stream_bufs
|
||||
buf = channel._stream_bufs["!room:matrix.org"]
|
||||
assert buf.text == "Hello"
|
||||
assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
assert len(client.room_send_calls) == 1
|
||||
assert client.room_send_calls[0]["content"]["body"] == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_appends_without_sending_before_edit_interval(monkeypatch) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
|
||||
now = 100.0
|
||||
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "Hello")
|
||||
assert len(client.room_send_calls) == 1
|
||||
|
||||
await channel.send_delta("!room:matrix.org", " world")
|
||||
assert len(client.room_send_calls) == 1
|
||||
|
||||
buf = channel._stream_bufs["!room:matrix.org"]
|
||||
assert buf.text == "Hello world"
|
||||
assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_edits_again_after_interval(monkeypatch) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
|
||||
|
||||
times = [100.0, 102.0, 104.0, 106.0, 108.0]
|
||||
times.reverse()
|
||||
monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop())
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "Hello")
|
||||
await channel.send_delta("!room:matrix.org", " world")
|
||||
|
||||
assert len(client.room_send_calls) == 2
|
||||
first_content = client.room_send_calls[0]["content"]
|
||||
second_content = client.room_send_calls[1]["content"]
|
||||
|
||||
assert "body" in first_content
|
||||
assert first_content["body"] == "Hello"
|
||||
assert "m.relates_to" not in first_content
|
||||
|
||||
assert "body" in second_content
|
||||
assert "m.relates_to" in second_content
|
||||
assert second_content["body"] == "Hello world"
|
||||
assert second_content["m.relates_to"] == {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_replaces_existing_message() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf(
|
||||
text="Final text",
|
||||
event_id="event-1",
|
||||
last_edit=100.0,
|
||||
)
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
|
||||
|
||||
assert "!room:matrix.org" not in channel._stream_bufs
|
||||
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
|
||||
assert len(client.room_send_calls) == 1
|
||||
assert client.room_send_calls[0]["content"]["body"] == "Final text"
|
||||
assert client.room_send_calls[0]["content"]["m.relates_to"] == {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "event-1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_starts_threaded_stream_inside_thread() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
client.room_send_response.event_id = "event-1"
|
||||
|
||||
metadata = {
|
||||
"thread_root_event_id": "$root1",
|
||||
"thread_reply_to_event_id": "$reply1",
|
||||
}
|
||||
await channel.send_delta("!room:matrix.org", "Hello", metadata)
|
||||
|
||||
assert client.room_send_calls[0]["content"]["m.relates_to"] == {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$root1",
|
||||
"m.in_reply_to": {"event_id": "$reply1"},
|
||||
"is_falling_back": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkeypatch) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
client.room_send_response.event_id = "event-1"
|
||||
|
||||
times = [100.0, 102.0, 104.0]
|
||||
times.reverse()
|
||||
monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop())
|
||||
|
||||
metadata = {
|
||||
"thread_root_event_id": "$root1",
|
||||
"thread_reply_to_event_id": "$reply1",
|
||||
}
|
||||
await channel.send_delta("!room:matrix.org", "Hello", metadata)
|
||||
await channel.send_delta("!room:matrix.org", " world", metadata)
|
||||
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True, **metadata})
|
||||
|
||||
edit_content = client.room_send_calls[1]["content"]
|
||||
final_content = client.room_send_calls[2]["content"]
|
||||
|
||||
assert edit_content["m.relates_to"] == {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "event-1",
|
||||
}
|
||||
assert edit_content["m.new_content"]["m.relates_to"] == {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$root1",
|
||||
"m.in_reply_to": {"event_id": "$reply1"},
|
||||
"is_falling_back": True,
|
||||
}
|
||||
assert final_content["m.relates_to"] == {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": "event-1",
|
||||
}
|
||||
assert final_content["m.new_content"]["m.relates_to"] == {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$root1",
|
||||
"m.in_reply_to": {"event_id": "$reply1"},
|
||||
"is_falling_back": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
|
||||
|
||||
assert client.room_send_calls == []
|
||||
assert client.typing_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_on_error_stops_typing(monkeypatch) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
client.raise_on_send = True
|
||||
channel.client = client
|
||||
|
||||
now = 100.0
|
||||
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
|
||||
|
||||
await channel.send_delta("!room:matrix.org", "Hello", {"room_id": "!room:matrix.org"})
|
||||
|
||||
assert "!room:matrix.org" in channel._stream_bufs
|
||||
assert channel._stream_bufs["!room:matrix.org"].text == "Hello"
|
||||
assert len(client.room_send_calls) == 1
|
||||
|
||||
assert len(client.typing_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_ignores_whitespace_only_delta(monkeypatch) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
now = 100.0
|
||||
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
|
||||
|
||||
await channel.send_delta("!room:matrix.org", " ")
|
||||
|
||||
assert "!room:matrix.org" in channel._stream_bufs
|
||||
assert channel._stream_bufs["!room:matrix.org"].text == " "
|
||||
assert client.room_send_calls == []
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for QQ channel ack_message feature.
|
||||
|
||||
Covers the four verification points from the PR:
|
||||
1. C2C message: ack appears instantly
|
||||
2. Group message: ack appears instantly
|
||||
3. ack_message set to "": no ack sent
|
||||
4. Custom ack_message text: correct text delivered
|
||||
Each test also verifies that normal message processing is not blocked.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from nanobot.channels import qq
|
||||
|
||||
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
|
||||
except ImportError:
|
||||
QQ_AVAILABLE = False
|
||||
|
||||
if not QQ_AVAILABLE:
|
||||
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.qq import QQChannel, QQConfig
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
def __init__(self) -> None:
|
||||
self.c2c_calls: list[dict] = []
|
||||
self.group_calls: list[dict] = []
|
||||
|
||||
async def post_c2c_message(self, **kwargs) -> None:
|
||||
self.c2c_calls.append(kwargs)
|
||||
|
||||
async def post_group_message(self, **kwargs) -> None:
|
||||
self.group_calls.append(kwargs)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.api = _FakeApi()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_sent_on_c2c_message() -> None:
|
||||
"""Ack is sent immediately for C2C messages, then normal processing continues."""
|
||||
channel = QQChannel(
|
||||
QQConfig(
|
||||
app_id="app",
|
||||
secret="secret",
|
||||
allow_from=["*"],
|
||||
ack_message="⏳ Processing...",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._client = _FakeClient()
|
||||
|
||||
data = SimpleNamespace(
|
||||
id="msg1",
|
||||
content="hello",
|
||||
author=SimpleNamespace(user_openid="user1"),
|
||||
attachments=[],
|
||||
)
|
||||
await channel._on_message(data, is_group=False)
|
||||
|
||||
assert len(channel._client.api.c2c_calls) >= 1
|
||||
ack_call = channel._client.api.c2c_calls[0]
|
||||
assert ack_call["content"] == "⏳ Processing..."
|
||||
assert ack_call["openid"] == "user1"
|
||||
assert ack_call["msg_id"] == "msg1"
|
||||
assert ack_call["msg_type"] == 0
|
||||
|
||||
msg = await channel.bus.consume_inbound()
|
||||
assert msg.content == "hello"
|
||||
assert msg.sender_id == "user1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_sent_on_group_message() -> None:
|
||||
"""Ack is sent immediately for group messages, then normal processing continues."""
|
||||
channel = QQChannel(
|
||||
QQConfig(
|
||||
app_id="app",
|
||||
secret="secret",
|
||||
allow_from=["*"],
|
||||
ack_message="⏳ Processing...",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._client = _FakeClient()
|
||||
|
||||
data = SimpleNamespace(
|
||||
id="msg2",
|
||||
content="hello group",
|
||||
group_openid="group123",
|
||||
author=SimpleNamespace(member_openid="user1"),
|
||||
attachments=[],
|
||||
)
|
||||
await channel._on_message(data, is_group=True)
|
||||
|
||||
assert len(channel._client.api.group_calls) >= 1
|
||||
ack_call = channel._client.api.group_calls[0]
|
||||
assert ack_call["content"] == "⏳ Processing..."
|
||||
assert ack_call["group_openid"] == "group123"
|
||||
assert ack_call["msg_id"] == "msg2"
|
||||
assert ack_call["msg_type"] == 0
|
||||
|
||||
msg = await channel.bus.consume_inbound()
|
||||
assert msg.content == "hello group"
|
||||
assert msg.chat_id == "group123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_ack_when_ack_message_empty() -> None:
|
||||
"""Setting ack_message to empty string disables the ack entirely."""
|
||||
channel = QQChannel(
|
||||
QQConfig(
|
||||
app_id="app",
|
||||
secret="secret",
|
||||
allow_from=["*"],
|
||||
ack_message="",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._client = _FakeClient()
|
||||
|
||||
data = SimpleNamespace(
|
||||
id="msg3",
|
||||
content="hello",
|
||||
author=SimpleNamespace(user_openid="user1"),
|
||||
attachments=[],
|
||||
)
|
||||
await channel._on_message(data, is_group=False)
|
||||
|
||||
assert len(channel._client.api.c2c_calls) == 0
|
||||
assert len(channel._client.api.group_calls) == 0
|
||||
|
||||
msg = await channel.bus.consume_inbound()
|
||||
assert msg.content == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_ack_message_text() -> None:
|
||||
"""Custom Chinese ack_message text is delivered correctly."""
|
||||
custom = "正在处理中,请稍候..."
|
||||
channel = QQChannel(
|
||||
QQConfig(
|
||||
app_id="app",
|
||||
secret="secret",
|
||||
allow_from=["*"],
|
||||
ack_message=custom,
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._client = _FakeClient()
|
||||
|
||||
data = SimpleNamespace(
|
||||
id="msg4",
|
||||
content="test input",
|
||||
author=SimpleNamespace(user_openid="user1"),
|
||||
attachments=[],
|
||||
)
|
||||
await channel._on_message(data, is_group=False)
|
||||
|
||||
assert len(channel._client.api.c2c_calls) >= 1
|
||||
ack_call = channel._client.api.c2c_calls[0]
|
||||
assert ack_call["content"] == custom
|
||||
|
||||
msg = await channel.bus.consume_inbound()
|
||||
assert msg.content == "test input"
|
||||
@@ -32,8 +32,10 @@ class _FakeHTTPXRequest:
|
||||
class _FakeUpdater:
|
||||
def __init__(self, on_start_polling) -> None:
|
||||
self._on_start_polling = on_start_polling
|
||||
self.start_polling_kwargs = None
|
||||
|
||||
async def start_polling(self, **kwargs) -> None:
|
||||
self.start_polling_kwargs = kwargs
|
||||
self._on_start_polling()
|
||||
|
||||
|
||||
@@ -133,6 +135,7 @@ def _make_telegram_update(
|
||||
entities=None,
|
||||
caption_entities=None,
|
||||
reply_to_message=None,
|
||||
location=None,
|
||||
):
|
||||
user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
|
||||
message = SimpleNamespace(
|
||||
@@ -147,6 +150,7 @@ def _make_telegram_update(
|
||||
voice=None,
|
||||
audio=None,
|
||||
document=None,
|
||||
location=location,
|
||||
media_group_id=None,
|
||||
message_thread_id=None,
|
||||
message_id=1,
|
||||
@@ -184,7 +188,11 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
|
||||
assert poll_req.kwargs["connection_pool_size"] == 4
|
||||
assert builder.request_value is api_req
|
||||
assert builder.get_updates_request_value is poll_req
|
||||
assert callable(app.updater.start_polling_kwargs["error_callback"])
|
||||
assert any(cmd.command == "status" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream_log" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream_restore" for cmd in app.bot.commands)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -304,6 +312,26 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
|
||||
assert recorded == [("warning", "Telegram network issue: proxy disconnected")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None:
|
||||
from telegram.error import NetworkError
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
recorded: list[tuple[str, str]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.logger.warning",
|
||||
lambda message, error: recorded.append(("warning", message.format(error))),
|
||||
)
|
||||
|
||||
await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
|
||||
|
||||
assert recorded == [("warning", "Telegram network issue: NetworkError")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> None:
|
||||
channel = TelegramChannel(
|
||||
@@ -359,6 +387,32 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_splits_oversized_reply() -> None:
|
||||
"""Final streamed reply exceeding Telegram limit is split into chunks."""
|
||||
from nanobot.channels.telegram import TELEGRAM_MAX_MESSAGE_LEN
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
channel._app.bot.edit_message_text = AsyncMock()
|
||||
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
|
||||
|
||||
oversized = "x" * (TELEGRAM_MAX_MESSAGE_LEN + 500)
|
||||
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
|
||||
|
||||
await channel.send_delta("123", "", {"_stream_end": True})
|
||||
|
||||
channel._app.bot.edit_message_text.assert_called_once()
|
||||
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
|
||||
assert len(edit_text) <= TELEGRAM_MAX_MESSAGE_LEN
|
||||
|
||||
channel._app.bot.send_message.assert_called_once()
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_new_stream_id_replaces_stale_buffer() -> None:
|
||||
channel = TelegramChannel(
|
||||
@@ -398,6 +452,23 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N
|
||||
assert channel._stream_bufs["123"].last_edit > 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_initial_send_keeps_message_in_thread() -> None:
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
|
||||
await channel.send_delta(
|
||||
"123",
|
||||
"hello",
|
||||
{"_stream_delta": True, "_stream_id": "s:0", "message_thread_id": 42},
|
||||
)
|
||||
|
||||
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
|
||||
|
||||
|
||||
def test_derive_topic_session_key_uses_thread_id() -> None:
|
||||
message = SimpleNamespace(
|
||||
chat=SimpleNamespace(type="supergroup"),
|
||||
@@ -408,6 +479,27 @@ def test_derive_topic_session_key_uses_thread_id() -> None:
|
||||
assert TelegramChannel._derive_topic_session_key(message) == "telegram:-100123:topic:42"
|
||||
|
||||
|
||||
def test_derive_topic_session_key_private_dm_thread() -> None:
|
||||
"""Private DM threads (Telegram Threaded Mode) must get their own session key."""
|
||||
message = SimpleNamespace(
|
||||
chat=SimpleNamespace(type="private"),
|
||||
chat_id=999,
|
||||
message_thread_id=7,
|
||||
)
|
||||
assert TelegramChannel._derive_topic_session_key(message) == "telegram:999:topic:7"
|
||||
|
||||
|
||||
def test_derive_topic_session_key_none_without_thread() -> None:
|
||||
"""No thread id → no topic session key, regardless of chat type."""
|
||||
for chat_type in ("private", "supergroup", "group"):
|
||||
message = SimpleNamespace(
|
||||
chat=SimpleNamespace(type=chat_type),
|
||||
chat_id=123,
|
||||
message_thread_id=None,
|
||||
)
|
||||
assert TelegramChannel._derive_topic_session_key(message) is None
|
||||
|
||||
|
||||
def test_get_extension_falls_back_to_original_filename() -> None:
|
||||
channel = TelegramChannel(TelegramConfig(), MessageBus())
|
||||
|
||||
@@ -647,43 +739,56 @@ async def test_group_policy_open_accepts_plain_group_message() -> None:
|
||||
assert channel._app.bot.get_me_calls == 0
|
||||
|
||||
|
||||
def test_extract_reply_context_no_reply() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reply_context_no_reply() -> None:
|
||||
"""When there is no reply_to_message, _extract_reply_context returns None."""
|
||||
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
|
||||
message = SimpleNamespace(reply_to_message=None)
|
||||
assert TelegramChannel._extract_reply_context(message) is None
|
||||
assert await channel._extract_reply_context(message) is None
|
||||
|
||||
|
||||
def test_extract_reply_context_with_text() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reply_context_with_text() -> None:
|
||||
"""When reply has text, return prefixed string."""
|
||||
reply = SimpleNamespace(text="Hello world", caption=None)
|
||||
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test"))
|
||||
message = SimpleNamespace(reply_to_message=reply)
|
||||
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]"
|
||||
assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]"
|
||||
|
||||
|
||||
def test_extract_reply_context_with_caption_only() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reply_context_with_caption_only() -> None:
|
||||
"""When reply has only caption (no text), caption is used."""
|
||||
reply = SimpleNamespace(text=None, caption="Photo caption")
|
||||
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test"))
|
||||
message = SimpleNamespace(reply_to_message=reply)
|
||||
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]"
|
||||
assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]"
|
||||
|
||||
|
||||
def test_extract_reply_context_truncation() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reply_context_truncation() -> None:
|
||||
"""Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN."""
|
||||
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100)
|
||||
reply = SimpleNamespace(text=long_text, caption=None)
|
||||
reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None))
|
||||
message = SimpleNamespace(reply_to_message=reply)
|
||||
result = TelegramChannel._extract_reply_context(message)
|
||||
result = await channel._extract_reply_context(message)
|
||||
assert result is not None
|
||||
assert result.startswith("[Reply to: ")
|
||||
assert result.endswith("...]")
|
||||
assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...")
|
||||
|
||||
|
||||
def test_extract_reply_context_no_text_returns_none() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reply_context_no_text_returns_none() -> None:
|
||||
"""When reply has no text/caption, _extract_reply_context returns None (media handled separately)."""
|
||||
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
|
||||
reply = SimpleNamespace(text=None, caption=None)
|
||||
message = SimpleNamespace(reply_to_message=reply)
|
||||
assert TelegramChannel._extract_reply_context(message) is None
|
||||
assert await channel._extract_reply_context(message) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -949,6 +1054,48 @@ async def test_forward_command_does_not_inject_reply_context() -> None:
|
||||
assert handled[0]["content"] == "/new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_command_preserves_dream_log_args_and_strips_bot_suffix() -> None:
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
handled = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle
|
||||
update = _make_telegram_update(text="/dream-log@nanobot_test deadbeef", reply_to_message=None)
|
||||
|
||||
await channel._forward_command(update, None)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == "/dream-log deadbeef"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_command_normalizes_telegram_safe_dream_aliases() -> None:
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
handled = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle
|
||||
update = _make_telegram_update(text="/dream_restore@nanobot_test deadbeef", reply_to_message=None)
|
||||
|
||||
await channel._forward_command(update, None)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == "/dream-restore deadbeef"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_help_includes_restart_command() -> None:
|
||||
channel = TelegramChannel(
|
||||
@@ -964,3 +1111,51 @@ async def test_on_help_includes_restart_command() -> None:
|
||||
help_text = update.message.reply_text.await_args.args[0]
|
||||
assert "/restart" in help_text
|
||||
assert "/status" in help_text
|
||||
assert "/dream" in help_text
|
||||
assert "/dream-log" in help_text
|
||||
assert "/dream-restore" in help_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_location_content() -> None:
|
||||
"""Location messages are forwarded as [location: lat, lon] content."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
handled = []
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
channel._handle_message = capture_handle
|
||||
channel._start_typing = lambda _chat_id: None
|
||||
|
||||
location = SimpleNamespace(latitude=48.8566, longitude=2.3522)
|
||||
update = _make_telegram_update(location=location)
|
||||
await channel._on_message(update, None)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["content"] == "[location: 48.8566, 2.3522]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_location_with_text() -> None:
|
||||
"""Location messages with accompanying text include both in content."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
handled = []
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
channel._handle_message = capture_handle
|
||||
channel._start_typing = lambda _chat_id: None
|
||||
|
||||
location = SimpleNamespace(latitude=51.5074, longitude=-0.1278)
|
||||
update = _make_telegram_update(text="meet me here", location=location)
|
||||
await channel._on_message(update, None)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert "meet me here" in handled[0]["content"]
|
||||
assert "[location: 51.5074, -0.1278]" in handled[0]["content"]
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
import nanobot.channels.weixin as weixin_mod
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.weixin import (
|
||||
ITEM_IMAGE,
|
||||
ITEM_TEXT,
|
||||
MESSAGE_TYPE_BOT,
|
||||
WEIXIN_CHANNEL_VERSION,
|
||||
_decrypt_aes_ecb,
|
||||
_encrypt_aes_ecb,
|
||||
WeixinChannel,
|
||||
WeixinConfig,
|
||||
)
|
||||
@@ -42,10 +47,12 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
|
||||
|
||||
assert headers["Authorization"] == "Bearer token"
|
||||
assert headers["SKRouteTag"] == "123"
|
||||
assert headers["iLink-App-Id"] == "bot"
|
||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
|
||||
|
||||
|
||||
def test_channel_version_matches_reference_plugin_version() -> None:
|
||||
assert WEIXIN_CHANNEL_VERSION == "1.0.3"
|
||||
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
|
||||
|
||||
|
||||
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||
@@ -169,6 +176,120 @@ async def test_process_message_extracts_media_and_preserves_paths() -> None:
|
||||
assert inbound.media == ["/tmp/test.jpg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_falls_back_to_referenced_media_when_no_top_level_media() -> None:
|
||||
channel, bus = _make_channel()
|
||||
channel._download_media_item = AsyncMock(return_value="/tmp/ref.jpg")
|
||||
|
||||
await channel._process_message(
|
||||
{
|
||||
"message_type": 1,
|
||||
"message_id": "m3-ref-fallback",
|
||||
"from_user_id": "wx-user",
|
||||
"context_token": "ctx-3-ref-fallback",
|
||||
"item_list": [
|
||||
{
|
||||
"type": ITEM_TEXT,
|
||||
"text_item": {"text": "reply to image"},
|
||||
"ref_msg": {
|
||||
"message_item": {
|
||||
"type": ITEM_IMAGE,
|
||||
"image_item": {"media": {"encrypt_query_param": "ref-enc"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
|
||||
|
||||
channel._download_media_item.assert_awaited_once_with(
|
||||
{"media": {"encrypt_query_param": "ref-enc"}},
|
||||
"image",
|
||||
)
|
||||
assert inbound.media == ["/tmp/ref.jpg"]
|
||||
assert "reply to image" in inbound.content
|
||||
assert "[image]" in inbound.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_does_not_use_referenced_fallback_when_top_level_media_exists() -> None:
|
||||
channel, bus = _make_channel()
|
||||
channel._download_media_item = AsyncMock(side_effect=["/tmp/top.jpg", "/tmp/ref.jpg"])
|
||||
|
||||
await channel._process_message(
|
||||
{
|
||||
"message_type": 1,
|
||||
"message_id": "m3-ref-no-fallback",
|
||||
"from_user_id": "wx-user",
|
||||
"context_token": "ctx-3-ref-no-fallback",
|
||||
"item_list": [
|
||||
{"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}},
|
||||
{
|
||||
"type": ITEM_TEXT,
|
||||
"text_item": {"text": "has top-level media"},
|
||||
"ref_msg": {
|
||||
"message_item": {
|
||||
"type": ITEM_IMAGE,
|
||||
"image_item": {"media": {"encrypt_query_param": "ref-enc"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
|
||||
|
||||
channel._download_media_item.assert_awaited_once_with(
|
||||
{"media": {"encrypt_query_param": "top-enc"}},
|
||||
"image",
|
||||
)
|
||||
assert inbound.media == ["/tmp/top.jpg"]
|
||||
assert "/tmp/ref.jpg" not in inbound.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_does_not_fallback_when_top_level_media_exists_but_download_fails() -> None:
|
||||
channel, bus = _make_channel()
|
||||
# Top-level image download fails (None), referenced image would succeed if fallback were triggered.
|
||||
channel._download_media_item = AsyncMock(side_effect=[None, "/tmp/ref.jpg"])
|
||||
|
||||
await channel._process_message(
|
||||
{
|
||||
"message_type": 1,
|
||||
"message_id": "m3-ref-no-fallback-on-failure",
|
||||
"from_user_id": "wx-user",
|
||||
"context_token": "ctx-3-ref-no-fallback-on-failure",
|
||||
"item_list": [
|
||||
{"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "top-enc"}}},
|
||||
{
|
||||
"type": ITEM_TEXT,
|
||||
"text_item": {"text": "quoted has media"},
|
||||
"ref_msg": {
|
||||
"message_item": {
|
||||
"type": ITEM_IMAGE,
|
||||
"image_item": {"media": {"encrypt_query_param": "ref-enc"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
|
||||
|
||||
# Should only attempt top-level media item; reference fallback must not activate.
|
||||
channel._download_media_item.assert_awaited_once_with(
|
||||
{"media": {"encrypt_query_param": "top-enc"}},
|
||||
"image",
|
||||
)
|
||||
assert inbound.media == []
|
||||
assert "[image]" in inbound.content
|
||||
assert "/tmp/ref.jpg" not in inbound.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_without_context_token_does_not_send_text() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
@@ -199,6 +320,70 @@ async def test_send_does_not_send_when_session_is_paused() -> None:
|
||||
channel._send_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_typing_ticket_fetches_and_caches_per_user() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-1"})
|
||||
|
||||
first = await channel._get_typing_ticket("wx-user", "ctx-1")
|
||||
second = await channel._get_typing_ticket("wx-user", "ctx-2")
|
||||
|
||||
assert first == "ticket-1"
|
||||
assert second == "ticket-1"
|
||||
channel._api_post.assert_awaited_once_with(
|
||||
"ilink/bot/getconfig",
|
||||
{"ilink_user_id": "wx-user", "context_token": "ctx-1", "base_info": weixin_mod.BASE_INFO},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-typing"
|
||||
channel._send_text = AsyncMock()
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"ret": 0, "typing_ticket": "ticket-typing"},
|
||||
{"ret": 0},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-typing")
|
||||
assert channel._api_post.await_count == 3
|
||||
assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig"
|
||||
assert channel._api_post.await_args_list[1].args[0] == "ilink/bot/sendtyping"
|
||||
assert channel._api_post.await_args_list[1].args[1]["status"] == 1
|
||||
assert channel._api_post.await_args_list[2].args[0] == "ilink/bot/sendtyping"
|
||||
assert channel._api_post.await_args_list[2].args[1]["status"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-no-ticket"
|
||||
channel._send_text = AsyncMock()
|
||||
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})
|
||||
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-no-ticket")
|
||||
channel._api_post.assert_awaited_once()
|
||||
assert channel._api_post.await_args_list[0].args[0] == "ilink/bot/getconfig"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
@@ -220,8 +405,12 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
|
||||
channel._api_get = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"status": "expired"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
]
|
||||
)
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "expired"},
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-2",
|
||||
@@ -247,12 +436,16 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
|
||||
channel._api_get = AsyncMock(
|
||||
side_effect=[
|
||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||
{"status": "expired"},
|
||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||
{"status": "expired"},
|
||||
{"qrcode": "qr-3", "qrcode_img_content": "url-3"},
|
||||
{"status": "expired"},
|
||||
{"qrcode": "qr-4", "qrcode_img_content": "url-4"},
|
||||
]
|
||||
)
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "expired"},
|
||||
{"status": "expired"},
|
||||
{"status": "expired"},
|
||||
{"status": "expired"},
|
||||
]
|
||||
)
|
||||
@@ -262,6 +455,105 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
status_side_effect = [
|
||||
{"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"},
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-3",
|
||||
"ilink_bot_id": "bot-3",
|
||||
"baseurl": "https://example.test",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
channel._api_get = AsyncMock(side_effect=list(status_side_effect))
|
||||
channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect))
|
||||
|
||||
ok = await channel._qr_login()
|
||||
|
||||
assert ok is True
|
||||
assert channel._token == "token-3"
|
||||
assert channel._api_get_with_base.await_count == 2
|
||||
first_call = channel._api_get_with_base.await_args_list[0]
|
||||
second_call = channel._api_get_with_base.await_args_list[1]
|
||||
assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
|
||||
assert second_call.kwargs["base_url"] == "https://idc.redirect.test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
status_side_effect = [
|
||||
{"status": "scaned_but_redirect"},
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-4",
|
||||
"ilink_bot_id": "bot-4",
|
||||
"baseurl": "https://example.test",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
channel._api_get = AsyncMock(side_effect=list(status_side_effect))
|
||||
channel._api_get_with_base = AsyncMock(side_effect=list(status_side_effect))
|
||||
|
||||
ok = await channel._qr_login()
|
||||
|
||||
assert ok is True
|
||||
assert channel._token == "token-4"
|
||||
assert channel._api_get_with_base.await_count == 2
|
||||
first_call = channel._api_get_with_base.await_args_list[0]
|
||||
second_call = channel._api_get_with_base.await_args_list[1]
|
||||
assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
|
||||
assert second_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_resets_redirect_base_url_after_qr_refresh() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])
|
||||
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
{"status": "scaned_but_redirect", "redirect_host": "idc.redirect.test"},
|
||||
{"status": "expired"},
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-5",
|
||||
"ilink_bot_id": "bot-5",
|
||||
"baseurl": "https://example.test",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
ok = await channel._qr_login()
|
||||
|
||||
assert ok is True
|
||||
assert channel._token == "token-5"
|
||||
assert channel._api_get_with_base.await_count == 3
|
||||
first_call = channel._api_get_with_base.await_args_list[0]
|
||||
second_call = channel._api_get_with_base.await_args_list[1]
|
||||
third_call = channel._api_get_with_base.await_args_list[2]
|
||||
assert first_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
|
||||
assert second_call.kwargs["base_url"] == "https://idc.redirect.test"
|
||||
assert third_call.kwargs["base_url"] == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_skips_bot_messages() -> None:
|
||||
channel, bus = _make_channel()
|
||||
@@ -278,3 +570,436 @@ async def test_process_message_skips_bot_messages() -> None:
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_starts_typing_on_inbound() -> None:
|
||||
"""Typing indicator fires immediately when user message arrives."""
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._start_typing = AsyncMock()
|
||||
|
||||
await channel._process_message(
|
||||
{
|
||||
"message_type": 1,
|
||||
"message_id": "m-typing",
|
||||
"from_user_id": "wx-user",
|
||||
"context_token": "ctx-typing",
|
||||
"item_list": [
|
||||
{"type": ITEM_TEXT, "text_item": {"text": "hello"}},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
channel._start_typing.assert_awaited_once_with("wx-user", "ctx-typing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_final_message_clears_typing_indicator() -> None:
|
||||
"""Non-progress send should cancel typing status."""
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-2"
|
||||
channel._typing_tickets["wx-user"] = {"ticket": "ticket-2", "next_fetch_at": 9999999999}
|
||||
channel._send_text = AsyncMock()
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
||||
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
|
||||
typing_cancel_calls = [
|
||||
c for c in channel._api_post.await_args_list
|
||||
if c.args[0] == "ilink/bot/sendtyping" and c.args[1]["status"] == 2
|
||||
]
|
||||
assert len(typing_cancel_calls) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_progress_message_keeps_typing_indicator() -> None:
|
||||
"""Progress messages must not cancel typing status."""
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-2"
|
||||
channel._typing_tickets["wx-user"] = {"ticket": "ticket-2", "next_fetch_at": 9999999999}
|
||||
channel._send_text = AsyncMock()
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
||||
|
||||
await channel.send(
|
||||
type(
|
||||
"Msg",
|
||||
(),
|
||||
{
|
||||
"chat_id": "wx-user",
|
||||
"content": "thinking",
|
||||
"media": [],
|
||||
"metadata": {"_progress": True},
|
||||
},
|
||||
)()
|
||||
)
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "thinking", "ctx-2")
|
||||
typing_cancel_calls = [
|
||||
c for c in channel._api_post.await_args_list
|
||||
if c.args and c.args[0] == "ilink/bot/sendtyping" and c.args[1].get("status") == 2
|
||||
]
|
||||
assert len(typing_cancel_calls) == 0
|
||||
|
||||
|
||||
class _DummyHttpResponse:
|
||||
def __init__(self, *, headers: dict[str, str] | None = None, status_code: int = 200) -> None:
|
||||
self.headers = headers or {}
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_uses_upload_full_url_when_present(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
|
||||
media_file = tmp_path / "photo.jpg"
|
||||
media_file.write_bytes(b"hello-weixin")
|
||||
|
||||
cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"}))
|
||||
channel._client = SimpleNamespace(post=cdn_post)
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{
|
||||
"upload_full_url": "https://upload-full.example.test/path?foo=bar",
|
||||
"upload_param": "should-not-be-used",
|
||||
},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
|
||||
await channel._send_media_file("wx-user", str(media_file), "ctx-1")
|
||||
|
||||
# first POST call is CDN upload
|
||||
cdn_url = cdn_post.await_args_list[0].args[0]
|
||||
assert cdn_url == "https://upload-full.example.test/path?foo=bar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_falls_back_to_upload_param_url(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
|
||||
media_file = tmp_path / "photo.jpg"
|
||||
media_file.write_bytes(b"hello-weixin")
|
||||
|
||||
cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "dl-param"}))
|
||||
channel._client = SimpleNamespace(post=cdn_post)
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"upload_param": "enc-need-fallback"},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
|
||||
await channel._send_media_file("wx-user", str(media_file), "ctx-1")
|
||||
|
||||
cdn_url = cdn_post.await_args_list[0].args[0]
|
||||
assert cdn_url.startswith(f"{channel.config.cdn_base_url}/upload?encrypted_query_param=enc-need-fallback")
|
||||
assert "&filekey=" in cdn_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_voice_file_uses_voice_item_and_voice_upload_type(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
|
||||
media_file = tmp_path / "voice.mp3"
|
||||
media_file.write_bytes(b"voice-bytes")
|
||||
|
||||
cdn_post = AsyncMock(return_value=_DummyHttpResponse(headers={"x-encrypted-param": "voice-dl-param"}))
|
||||
channel._client = SimpleNamespace(post=cdn_post)
|
||||
channel._api_post = AsyncMock(
|
||||
side_effect=[
|
||||
{"upload_full_url": "https://upload-full.example.test/voice?foo=bar"},
|
||||
{"ret": 0},
|
||||
]
|
||||
)
|
||||
|
||||
await channel._send_media_file("wx-user", str(media_file), "ctx-voice")
|
||||
|
||||
getupload_body = channel._api_post.await_args_list[0].args[1]
|
||||
assert getupload_body["media_type"] == 4
|
||||
|
||||
sendmessage_body = channel._api_post.await_args_list[1].args[1]
|
||||
item = sendmessage_body["msg"]["item_list"][0]
|
||||
assert item["type"] == 3
|
||||
assert "voice_item" in item
|
||||
assert "file_item" not in item
|
||||
assert item["voice_item"]["media"]["encrypt_query_param"] == "voice-dl-param"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_typing_uses_keepalive_until_send_finishes() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-typing-loop"
|
||||
async def _api_post_side_effect(endpoint: str, _body: dict | None = None, *, auth: bool = True):
|
||||
if endpoint == "ilink/bot/getconfig":
|
||||
return {"ret": 0, "typing_ticket": "ticket-keepalive"}
|
||||
return {"ret": 0}
|
||||
|
||||
channel._api_post = AsyncMock(side_effect=_api_post_side_effect)
|
||||
|
||||
async def _slow_send_text(*_args, **_kwargs) -> None:
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
channel._send_text = AsyncMock(side_effect=_slow_send_text)
|
||||
|
||||
old_interval = weixin_mod.TYPING_KEEPALIVE_INTERVAL_S
|
||||
weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = 0.01
|
||||
try:
|
||||
await channel.send(
|
||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||
)
|
||||
finally:
|
||||
weixin_mod.TYPING_KEEPALIVE_INTERVAL_S = old_interval
|
||||
|
||||
status_calls = [
|
||||
c.args[1]["status"]
|
||||
for c in channel._api_post.await_args_list
|
||||
if c.args and c.args[0] == "ilink/bot/sendtyping"
|
||||
]
|
||||
assert status_calls.count(1) >= 2
|
||||
assert status_calls[-1] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypatch) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
|
||||
now = {"value": 1000.0}
|
||||
monkeypatch.setattr(weixin_mod.time, "time", lambda: now["value"])
|
||||
monkeypatch.setattr(weixin_mod.random, "random", lambda: 0.5)
|
||||
|
||||
channel._api_post = AsyncMock(return_value={"ret": 0, "typing_ticket": "ticket-ok"})
|
||||
first = await channel._get_typing_ticket("wx-user", "ctx-1")
|
||||
assert first == "ticket-ok"
|
||||
|
||||
# force refresh window reached
|
||||
now["value"] = now["value"] + (12 * 60 * 60) + 1
|
||||
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "temporary failure"})
|
||||
|
||||
# On refresh failure, should still return cached ticket and apply backoff.
|
||||
second = await channel._get_typing_ticket("wx-user", "ctx-2")
|
||||
assert second == "ticket-ok"
|
||||
assert channel._api_post.await_count == 1
|
||||
|
||||
# Before backoff expiry, no extra fetch should happen.
|
||||
now["value"] += 1
|
||||
third = await channel._get_typing_ticket("wx-user", "ctx-3")
|
||||
assert third == "ticket-ok"
|
||||
assert channel._api_post.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status")
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.ConnectError("temporary network", request=request),
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-net-ok",
|
||||
"ilink_bot_id": "bot-id",
|
||||
"baseurl": "https://example.test",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
ok = await channel._qr_login()
|
||||
|
||||
assert ok is True
|
||||
assert channel._token == "token-net-ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
channel._print_qr_code = lambda url: None
|
||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||
|
||||
request = httpx.Request("GET", "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status")
|
||||
response = httpx.Response(status_code=524, request=request)
|
||||
channel._api_get_with_base = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.HTTPStatusError("gateway timeout", request=request, response=response),
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token-5xx-ok",
|
||||
"ilink_bot_id": "bot-id",
|
||||
"baseurl": "https://example.test",
|
||||
"ilink_user_id": "wx-user",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
ok = await channel._qr_login()
|
||||
|
||||
assert ok is True
|
||||
assert channel._token == "token-5xx-ok"
|
||||
|
||||
|
||||
def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
|
||||
key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" # base64("0123456789abcdef")
|
||||
plaintext = b"hello-weixin-padding"
|
||||
|
||||
ciphertext = _encrypt_aes_ecb(plaintext, key_b64)
|
||||
decrypted = _decrypt_aes_ecb(ciphertext, key_b64)
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
|
||||
class _DummyDownloadResponse:
|
||||
def __init__(self, content: bytes, status_code: int = 200) -> None:
|
||||
self.content = content
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _DummyErrorDownloadResponse(_DummyDownloadResponse):
|
||||
def __init__(self, url: str, status_code: int) -> None:
|
||||
super().__init__(content=b"", status_code=status_code)
|
||||
self._url = url
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
request = httpx.Request("GET", self._url)
|
||||
response = httpx.Response(self.status_code, request=request)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"download failed with status {self.status_code}",
|
||||
request=request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_item_uses_full_url_when_present(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
weixin_mod.get_media_dir = lambda _name: tmp_path
|
||||
|
||||
full_url = "https://cdn.example.test/download/full"
|
||||
channel._client = SimpleNamespace(
|
||||
get=AsyncMock(return_value=_DummyDownloadResponse(content=b"raw-image-bytes"))
|
||||
)
|
||||
|
||||
item = {
|
||||
"media": {
|
||||
"full_url": full_url,
|
||||
"encrypt_query_param": "enc-fallback-should-not-be-used",
|
||||
},
|
||||
}
|
||||
saved_path = await channel._download_media_item(item, "image")
|
||||
|
||||
assert saved_path is not None
|
||||
assert Path(saved_path).read_bytes() == b"raw-image-bytes"
|
||||
channel._client.get.assert_awaited_once_with(full_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_item_falls_back_when_full_url_returns_retryable_error(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
weixin_mod.get_media_dir = lambda _name: tmp_path
|
||||
|
||||
full_url = "https://cdn.example.test/download/full?taskid=123"
|
||||
channel._client = SimpleNamespace(
|
||||
get=AsyncMock(
|
||||
side_effect=[
|
||||
_DummyErrorDownloadResponse(full_url, 500),
|
||||
_DummyDownloadResponse(content=b"fallback-bytes"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
item = {
|
||||
"media": {
|
||||
"full_url": full_url,
|
||||
"encrypt_query_param": "enc-fallback",
|
||||
},
|
||||
}
|
||||
saved_path = await channel._download_media_item(item, "image")
|
||||
|
||||
assert saved_path is not None
|
||||
assert Path(saved_path).read_bytes() == b"fallback-bytes"
|
||||
assert channel._client.get.await_count == 2
|
||||
assert channel._client.get.await_args_list[0].args[0] == full_url
|
||||
fallback_url = channel._client.get.await_args_list[1].args[0]
|
||||
assert fallback_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_item_falls_back_to_encrypt_query_param(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
weixin_mod.get_media_dir = lambda _name: tmp_path
|
||||
|
||||
channel._client = SimpleNamespace(
|
||||
get=AsyncMock(return_value=_DummyDownloadResponse(content=b"fallback-bytes"))
|
||||
)
|
||||
|
||||
item = {"media": {"encrypt_query_param": "enc-fallback"}}
|
||||
saved_path = await channel._download_media_item(item, "image")
|
||||
|
||||
assert saved_path is not None
|
||||
assert Path(saved_path).read_bytes() == b"fallback-bytes"
|
||||
called_url = channel._client.get.await_args_list[0].args[0]
|
||||
assert called_url.startswith(f"{channel.config.cdn_base_url}/download?encrypted_query_param=enc-fallback")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_item_does_not_retry_when_full_url_fails_without_fallback(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
weixin_mod.get_media_dir = lambda _name: tmp_path
|
||||
|
||||
full_url = "https://cdn.example.test/download/full"
|
||||
channel._client = SimpleNamespace(
|
||||
get=AsyncMock(return_value=_DummyErrorDownloadResponse(full_url, 500))
|
||||
)
|
||||
|
||||
item = {"media": {"full_url": full_url}}
|
||||
saved_path = await channel._download_media_item(item, "image")
|
||||
|
||||
assert saved_path is None
|
||||
channel._client.get.assert_awaited_once_with(full_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_item_non_image_requires_aes_key_even_with_full_url(tmp_path) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
weixin_mod.get_media_dir = lambda _name: tmp_path
|
||||
|
||||
full_url = "https://cdn.example.test/download/voice"
|
||||
channel._client = SimpleNamespace(
|
||||
get=AsyncMock(return_value=_DummyDownloadResponse(content=b"ciphertext-or-unknown"))
|
||||
)
|
||||
|
||||
item = {
|
||||
"media": {
|
||||
"full_url": full_url,
|
||||
},
|
||||
}
|
||||
saved_path = await channel._download_media_item(item, "voice")
|
||||
|
||||
assert saved_path is None
|
||||
channel._client.get.assert_not_awaited()
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Tests for WhatsApp channel outbound media support."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.whatsapp import WhatsAppChannel
|
||||
from nanobot.channels.whatsapp import (
|
||||
WhatsAppChannel,
|
||||
_load_or_create_bridge_token,
|
||||
)
|
||||
|
||||
|
||||
def _make_channel() -> WhatsAppChannel:
|
||||
@@ -155,3 +161,197 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["chat_id"] == "12345@g.us"
|
||||
assert kwargs["sender_id"] == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_id_prefers_phone_jid_over_lid():
|
||||
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "lid1",
|
||||
"sender": "ABC123@lid.whatsapp.net",
|
||||
"pn": "5551234@s.whatsapp.net",
|
||||
"content": "hi",
|
||||
"timestamp": 1,
|
||||
})
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "5551234"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
||||
"""When only LID is present, a cached LID→phone mapping should be used."""
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
# First message: both phone and LID → builds cache
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "c1",
|
||||
"sender": "LID99@lid.whatsapp.net",
|
||||
"pn": "5559999@s.whatsapp.net",
|
||||
"content": "first",
|
||||
"timestamp": 1,
|
||||
})
|
||||
)
|
||||
# Second message: only LID, no phone
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "c2",
|
||||
"sender": "LID99@lid.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "second",
|
||||
"timestamp": 2,
|
||||
})
|
||||
)
|
||||
|
||||
second_kwargs = ch._handle_message.await_args_list[1].kwargs
|
||||
assert second_kwargs["sender_id"] == "5559999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_transcription_uses_media_path():
|
||||
"""Voice messages are transcribed when media path is available."""
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
ch.transcription_provider = "openai"
|
||||
ch.transcription_api_key = "sk-test"
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Hello world")
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v1",
|
||||
"sender": "12345@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
"media": ["/tmp/voice.ogg"],
|
||||
})
|
||||
)
|
||||
|
||||
ch.transcribe_audio.assert_awaited_once_with("/tmp/voice.ogg")
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"].startswith("Hello world")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_no_media_shows_not_available():
|
||||
"""Voice messages without media produce a fallback placeholder."""
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v2",
|
||||
"sender": "12345@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
})
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "[Voice Message: Audio not available]"
|
||||
|
||||
|
||||
def test_load_or_create_bridge_token_persists_generated_secret(tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
|
||||
first = _load_or_create_bridge_token(token_path)
|
||||
second = _load_or_create_bridge_token(token_path)
|
||||
|
||||
assert first == second
|
||||
assert token_path.read_text(encoding="utf-8") == first
|
||||
assert len(first) >= 32
|
||||
if os.name != "nt":
|
||||
assert token_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_configured_bridge_token_skips_local_token_file(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
ch = WhatsAppChannel({"enabled": True, "bridgeToken": "manual-secret"}, MagicMock())
|
||||
|
||||
assert ch._effective_bridge_token() == "manual-secret"
|
||||
assert not token_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_exports_effective_bridge_token(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._ensure_bridge_setup", lambda: bridge_dir)
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp.shutil.which", lambda _: "/usr/bin/npm")
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp.subprocess.run", fake_run)
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
|
||||
assert await ch.login() is True
|
||||
assert len(calls) == 1
|
||||
|
||||
_, kwargs = calls[0]
|
||||
assert kwargs["cwd"] == bridge_dir
|
||||
assert kwargs["env"]["AUTH_DIR"] == str(token_path.parent)
|
||||
assert kwargs["env"]["BRIDGE_TOKEN"] == token_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
sent_messages: list[str] = []
|
||||
|
||||
class FakeWS:
|
||||
def __init__(self) -> None:
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
sent_messages.append(message)
|
||||
ch._running = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise StopAsyncIteration
|
||||
|
||||
class FakeConnect:
|
||||
def __init__(self, ws):
|
||||
self.ws = ws
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.ws
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"websockets",
|
||||
types.SimpleNamespace(connect=lambda url: FakeConnect(FakeWS())),
|
||||
)
|
||||
|
||||
ch = WhatsAppChannel({"enabled": True, "bridgeUrl": "ws://localhost:3001"}, MagicMock())
|
||||
await ch.start()
|
||||
|
||||
assert sent_messages == [
|
||||
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
|
||||
]
|
||||
|
||||
@@ -145,3 +145,29 @@ def test_response_renderable_without_metadata_keeps_markdown_path():
|
||||
renderable = commands._response_renderable(help_text, render_markdown=True)
|
||||
|
||||
assert renderable.__class__.__name__ == "Markdown"
|
||||
|
||||
|
||||
def test_stream_renderer_stop_for_input_stops_spinner():
|
||||
"""stop_for_input should stop the active spinner to avoid prompt_toolkit conflicts."""
|
||||
spinner = MagicMock()
|
||||
mock_console = MagicMock()
|
||||
mock_console.status.return_value = spinner
|
||||
|
||||
# Create renderer with mocked console
|
||||
with patch.object(stream_mod, "_make_console", return_value=mock_console):
|
||||
renderer = stream_mod.StreamRenderer(show_spinner=True)
|
||||
|
||||
# Verify spinner started
|
||||
spinner.start.assert_called_once()
|
||||
|
||||
# Stop for input
|
||||
renderer.stop_for_input()
|
||||
|
||||
# Verify spinner stopped
|
||||
spinner.stop.assert_called_once()
|
||||
|
||||
|
||||
def test_make_console_uses_force_terminal():
|
||||
"""Console should be created with force_terminal=True for proper ANSI handling."""
|
||||
console = stream_mod._make_console()
|
||||
assert console._force_terminal is True
|
||||
|
||||
+373
-89
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -9,6 +11,7 @@ from typer.testing import CliRunner
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.cli.commands import _make_provider, app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
@@ -19,11 +22,6 @@ class _StopGatewayError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths():
|
||||
"""Mock config/workspace paths for test isolation."""
|
||||
@@ -31,7 +29,6 @@ def mock_paths():
|
||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
||||
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
||||
|
||||
base_dir = Path("./test_onboard_data")
|
||||
if base_dir.exists():
|
||||
shutil.rmtree(base_dir)
|
||||
@@ -317,6 +314,75 @@ def test_openai_compat_provider_passes_model_through():
|
||||
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
||||
|
||||
|
||||
def test_make_provider_uses_github_copilot_backend():
|
||||
from nanobot.cli.commands import _make_provider
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "github-copilot",
|
||||
"model": "github-copilot/gpt-4.1",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = _make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
def test_github_copilot_provider_strips_prefixed_model_name():
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1")
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="github-copilot/gpt-5.1",
|
||||
max_tokens=16,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["model"] == "gpt-5.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_copilot_provider_refreshes_client_api_key_before_chat():
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.api_key = "no-key"
|
||||
mock_client.chat.completions.create = AsyncMock(return_value={
|
||||
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
|
||||
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1")
|
||||
|
||||
provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token")
|
||||
|
||||
response = await provider.chat(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="github-copilot/gpt-5.1",
|
||||
max_tokens=16,
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert provider._client.api_key == "copilot-access-token"
|
||||
provider._get_copilot_access_token.assert_awaited_once()
|
||||
mock_client.chat.completions.create.assert_awaited_once()
|
||||
|
||||
|
||||
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
@@ -356,13 +422,13 @@ def mock_agent_runtime(tmp_path):
|
||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||
|
||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||
patch("nanobot.bus.queue.MessageBus"), \
|
||||
patch("nanobot.cron.service.CronService"), \
|
||||
patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
|
||||
|
||||
agent_loop = MagicMock()
|
||||
agent_loop.channels_config = None
|
||||
agent_loop.process_direct = AsyncMock(
|
||||
@@ -587,7 +653,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||
|
||||
@@ -642,27 +710,106 @@ def test_heartbeat_retains_recent_messages_by_default():
|
||||
assert config.gateway.heartbeat.keep_recent_messages == 8
|
||||
|
||||
|
||||
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
|
||||
def _write_instance_config(tmp_path: Path) -> Path:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
return config_file
|
||||
|
||||
|
||||
def _stop_gateway_provider(_config) -> object:
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
|
||||
def _patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config: Config,
|
||||
*,
|
||||
set_config_path=None,
|
||||
sync_templates=None,
|
||||
make_provider=None,
|
||||
message_bus=None,
|
||||
session_manager=None,
|
||||
cron_service=None,
|
||||
get_cron_dir=None,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
set_config_path or (lambda _path: None),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
sync_templates or (lambda _path: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
make_provider or (lambda _config: object()),
|
||||
)
|
||||
|
||||
if message_bus is not None:
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
|
||||
if session_manager is not None:
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", session_manager)
|
||||
if cron_service is not None:
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", cron_service)
|
||||
if get_cron_dir is not None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
|
||||
|
||||
|
||||
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
class _FakeApiApp:
|
||||
def __init__(self) -> None:
|
||||
self.on_startup: list[object] = []
|
||||
self.on_cleanup: list[object] = []
|
||||
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["workspace"] = kwargs["workspace"]
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
return None
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
return None
|
||||
|
||||
def _fake_create_app(agent_loop, model_name: str, request_timeout: float):
|
||||
seen["agent_loop"] = agent_loop
|
||||
seen["model_name"] = model_name
|
||||
seen["request_timeout"] = request_timeout
|
||||
return _FakeApiApp()
|
||||
|
||||
def _fake_run_app(api_app, host: str, port: int, print):
|
||||
seen["api_app"] = api_app
|
||||
seen["host"] = host
|
||||
seen["port"] = port
|
||||
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
||||
|
||||
|
||||
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
lambda path: seen.__setitem__("config_path", path),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
lambda path: seen.__setitem__("workspace", path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
set_config_path=lambda path: seen.__setitem__("config_path", path),
|
||||
sync_templates=lambda path: seen.__setitem__("workspace", path),
|
||||
make_provider=_stop_gateway_provider,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
@@ -673,24 +820,17 @@ def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Pa
|
||||
|
||||
|
||||
def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
override = tmp_path / "override-workspace"
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
lambda path: seen.__setitem__("workspace", path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
sync_templates=lambda path: seen.__setitem__("workspace", path),
|
||||
make_provider=_stop_gateway_provider,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
@@ -704,27 +844,23 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path)
|
||||
|
||||
|
||||
def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
class _StopCron:
|
||||
def __init__(self, store_path: Path) -> None:
|
||||
seen["cron_store"] = store_path
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
cron_service=_StopCron,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
@@ -732,13 +868,119 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
|
||||
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
|
||||
|
||||
|
||||
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
provider = object()
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
class _FakeCron:
|
||||
def __init__(self, _store_path: Path) -> None:
|
||||
self.on_job = None
|
||||
seen["cron"] = self
|
||||
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.tools = {}
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
return OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="user-1",
|
||||
content="Time to stretch.",
|
||||
)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
return None
|
||||
|
||||
async def run(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
class _StopAfterCronSetup:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
async def _capture_evaluate_response(
|
||||
response: str,
|
||||
task_context: str,
|
||||
provider_arg: object,
|
||||
model: str,
|
||||
) -> bool:
|
||||
seen["response"] = response
|
||||
seen["task_context"] = task_context
|
||||
seen["provider"] = provider_arg
|
||||
seen["model"] = model
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.evaluator.evaluate_response",
|
||||
_capture_evaluate_response,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
assert isinstance(result.exception, _StopGatewayError)
|
||||
cron = seen["cron"]
|
||||
assert isinstance(cron, _FakeCron)
|
||||
assert cron.on_job is not None
|
||||
|
||||
job = CronJob(
|
||||
id="cron-1",
|
||||
name="stretch",
|
||||
payload=CronPayload(
|
||||
message="Remind me to stretch.",
|
||||
deliver=True,
|
||||
channel="telegram",
|
||||
to="user-1",
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(job))
|
||||
|
||||
assert response == "Time to stretch."
|
||||
assert seen["response"] == "Time to stretch."
|
||||
assert seen["provider"] is provider
|
||||
assert seen["model"] == "test-model"
|
||||
assert seen["task_context"] == (
|
||||
"[Scheduled Task] Timer finished.\n\n"
|
||||
"Task 'stretch' has been triggered.\n"
|
||||
"Scheduled instruction: Remind me to stretch."
|
||||
)
|
||||
bus.publish_outbound.assert_awaited_once_with(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="user-1",
|
||||
content="Time to stretch.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
legacy_dir = tmp_path / "global" / "cron"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
legacy_file = legacy_dir / "jobs.json"
|
||||
@@ -748,20 +990,19 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
config = Config()
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
|
||||
class _StopCron:
|
||||
def __init__(self, store_path: Path) -> None:
|
||||
seen["cron_store"] = store_path
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
cron_service=_StopCron,
|
||||
get_cron_dir=lambda: legacy_dir,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -777,10 +1018,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
legacy_dir = tmp_path / "global" / "cron"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
legacy_file = legacy_dir / "jobs.json"
|
||||
@@ -791,20 +1029,19 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
config.agents.defaults.workspace = str(custom_workspace)
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
|
||||
class _StopCron:
|
||||
def __init__(self, store_path: Path) -> None:
|
||||
seen["cron_store"] = store_path
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
cron_service=_StopCron,
|
||||
get_cron_dir=lambda: legacy_dir,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
@@ -856,19 +1093,14 @@ def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) ->
|
||||
|
||||
|
||||
def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.gateway.port = 18791
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
make_provider=_stop_gateway_provider,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
@@ -878,19 +1110,14 @@ def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_
|
||||
|
||||
|
||||
def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.gateway.port = 18791
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
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.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
make_provider=_stop_gateway_provider,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"])
|
||||
@@ -899,6 +1126,63 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
|
||||
assert "port 18792" in result.stdout
|
||||
|
||||
|
||||
def test_serve_uses_api_config_defaults_and_workspace_override(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
config.api.host = "127.0.0.2"
|
||||
config.api.port = 18900
|
||||
config.api.timeout = 45.0
|
||||
override_workspace = tmp_path / "override-workspace"
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
_patch_serve_runtime(monkeypatch, config, seen)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["serve", "--config", str(config_file), "--workspace", str(override_workspace)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["workspace"] == override_workspace
|
||||
assert seen["host"] == "127.0.0.2"
|
||||
assert seen["port"] == 18900
|
||||
assert seen["request_timeout"] == 45.0
|
||||
|
||||
|
||||
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
config.api.host = "127.0.0.2"
|
||||
config.api.port = 18900
|
||||
config.api.timeout = 45.0
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
_patch_serve_runtime(monkeypatch, config, seen)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"serve",
|
||||
"--config",
|
||||
str(config_file),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"18901",
|
||||
"--timeout",
|
||||
"46",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["host"] == "127.0.0.1"
|
||||
assert seen["port"] == 18901
|
||||
assert seen["request_timeout"] == 46.0
|
||||
|
||||
|
||||
def test_channels_login_requires_channel_name() -> None:
|
||||
result = runner.invoke(app, ["channels", "login"])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -36,14 +37,23 @@ class TestRestartCommand:
|
||||
async def test_restart_sends_message_and_calls_execv(self):
|
||||
from nanobot.command.builtin import cmd_restart
|
||||
from nanobot.command.router import CommandContext
|
||||
from nanobot.utils.restart import (
|
||||
RESTART_NOTIFY_CHANNEL_ENV,
|
||||
RESTART_NOTIFY_CHAT_ID_ENV,
|
||||
RESTART_STARTED_AT_ENV,
|
||||
)
|
||||
|
||||
loop, bus = _make_loop()
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
|
||||
|
||||
with patch("nanobot.command.builtin.os.execv") as mock_execv:
|
||||
with patch.dict(os.environ, {}, clear=False), \
|
||||
patch("nanobot.command.builtin.os.execv") as mock_execv:
|
||||
out = await cmd_restart(ctx)
|
||||
assert "Restarting" in out.content
|
||||
assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli"
|
||||
assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct"
|
||||
assert os.environ.get(RESTART_STARTED_AT_ENV)
|
||||
|
||||
await asyncio.sleep(1.5)
|
||||
mock_execv.assert_called_once()
|
||||
@@ -127,7 +137,7 @@ class TestRestartCommand:
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
loop._start_time = time.time() - 125
|
||||
loop._last_usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(20500, "tiktoken")
|
||||
)
|
||||
|
||||
@@ -138,7 +148,7 @@ class TestRestartCommand:
|
||||
assert response is not None
|
||||
assert "Model: test-model" in response.content
|
||||
assert "Tokens: 0 in / 0 out" in response.content
|
||||
assert "Context: 20k/64k (31%)" in response.content
|
||||
assert "Context: 20k/65k (31%)" in response.content
|
||||
assert "Session: 3 messages" in response.content
|
||||
assert "Uptime: 2m 5s" in response.content
|
||||
assert response.metadata == {"render_as": "text"}
|
||||
@@ -152,10 +162,12 @@ class TestRestartCommand:
|
||||
])
|
||||
|
||||
await loop._run_agent_loop([])
|
||||
assert loop._last_usage == {"prompt_tokens": 9, "completion_tokens": 4}
|
||||
assert loop._last_usage["prompt_tokens"] == 9
|
||||
assert loop._last_usage["completion_tokens"] == 4
|
||||
|
||||
await loop._run_agent_loop([])
|
||||
assert loop._last_usage == {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
assert loop._last_usage["prompt_tokens"] == 0
|
||||
assert loop._last_usage["completion_tokens"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):
|
||||
@@ -164,7 +176,7 @@ class TestRestartCommand:
|
||||
session.get_history.return_value = [{"role": "user"}]
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
loop._last_usage = {"prompt_tokens": 1200, "completion_tokens": 34}
|
||||
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
loop.consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(0, "none")
|
||||
)
|
||||
|
||||
@@ -174,7 +186,7 @@ class TestRestartCommand:
|
||||
|
||||
assert response is not None
|
||||
assert "Tokens: 1200 in / 34 out" in response.content
|
||||
assert "Context: 1k/64k (1%)" in response.content
|
||||
assert "Context: 1k/65k (1%)" in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_preserves_render_metadata(self):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Regression tests for SafeFileHistory (issue #2846).
|
||||
|
||||
Surrogate characters in CLI input must not crash history file writes.
|
||||
"""
|
||||
|
||||
from nanobot.cli.commands import SafeFileHistory
|
||||
|
||||
|
||||
class TestSafeFileHistory:
|
||||
def test_surrogate_replaced(self, tmp_path):
|
||||
"""Surrogate pairs are replaced with U+FFFD, not crash."""
|
||||
hist = SafeFileHistory(str(tmp_path / "history"))
|
||||
hist.store_string("hello \udce9 world")
|
||||
entries = list(hist.load_history_strings())
|
||||
assert len(entries) == 1
|
||||
assert "\udce9" not in entries[0]
|
||||
assert "hello" in entries[0]
|
||||
assert "world" in entries[0]
|
||||
|
||||
def test_normal_text_unchanged(self, tmp_path):
|
||||
hist = SafeFileHistory(str(tmp_path / "history"))
|
||||
hist.store_string("normal ascii text")
|
||||
entries = list(hist.load_history_strings())
|
||||
assert entries[0] == "normal ascii text"
|
||||
|
||||
def test_emoji_preserved(self, tmp_path):
|
||||
hist = SafeFileHistory(str(tmp_path / "history"))
|
||||
hist.store_string("hello 🐈 nanobot")
|
||||
entries = list(hist.load_history_strings())
|
||||
assert entries[0] == "hello 🐈 nanobot"
|
||||
|
||||
def test_mixed_unicode_preserved(self, tmp_path):
|
||||
"""CJK + emoji + latin should all pass through cleanly."""
|
||||
hist = SafeFileHistory(str(tmp_path / "history"))
|
||||
hist.store_string("你好 hello こんにちは 🎉")
|
||||
entries = list(hist.load_history_strings())
|
||||
assert entries[0] == "你好 hello こんにちは 🎉"
|
||||
|
||||
def test_multiple_surrogates(self, tmp_path):
|
||||
hist = SafeFileHistory(str(tmp_path / "history"))
|
||||
hist.store_string("\udce9\udcf1\udcff")
|
||||
entries = list(hist.load_history_strings())
|
||||
assert len(entries) == 1
|
||||
assert "\udce9" not in entries[0]
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.command.builtin import cmd_dream_log, cmd_dream_restore
|
||||
from nanobot.command.router import CommandContext
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, git, last_dream_cursor: int = 1):
|
||||
self.git = git
|
||||
self._last_dream_cursor = last_dream_cursor
|
||||
|
||||
def get_last_dream_cursor(self) -> int:
|
||||
return self._last_dream_cursor
|
||||
|
||||
|
||||
class _FakeGit:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
initialized: bool = True,
|
||||
commits: list[CommitInfo] | None = None,
|
||||
diff_map: dict[str, tuple[CommitInfo, str] | None] | None = None,
|
||||
revert_result: str | None = None,
|
||||
):
|
||||
self._initialized = initialized
|
||||
self._commits = commits or []
|
||||
self._diff_map = diff_map or {}
|
||||
self._revert_result = revert_result
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
return self._initialized
|
||||
|
||||
def log(self, max_entries: int = 20) -> list[CommitInfo]:
|
||||
return self._commits[:max_entries]
|
||||
|
||||
def show_commit_diff(self, sha: str, max_entries: int = 20):
|
||||
return self._diff_map.get(sha)
|
||||
|
||||
def revert(self, sha: str) -> str | None:
|
||||
return self._revert_result
|
||||
|
||||
|
||||
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
|
||||
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
|
||||
loop = SimpleNamespace(consolidator=SimpleNamespace(store=store))
|
||||
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_log_latest_is_more_user_friendly() -> None:
|
||||
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
|
||||
diff = (
|
||||
"diff --git a/SOUL.md b/SOUL.md\n"
|
||||
"--- a/SOUL.md\n"
|
||||
"+++ b/SOUL.md\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
)
|
||||
git = _FakeGit(commits=[commit], diff_map={commit.sha: (commit, diff)})
|
||||
|
||||
out = await cmd_dream_log(_make_ctx("/dream-log", git))
|
||||
|
||||
assert "## Dream Update" in out.content
|
||||
assert "Here is the latest Dream memory change." in out.content
|
||||
assert "- Commit: `abcd1234`" in out.content
|
||||
assert "- Changed files: `SOUL.md`" in out.content
|
||||
assert "Use `/dream-restore abcd1234` to undo this change." in out.content
|
||||
assert "```diff" in out.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_log_missing_commit_guides_user() -> None:
|
||||
git = _FakeGit(diff_map={})
|
||||
|
||||
out = await cmd_dream_log(_make_ctx("/dream-log deadbeef", git, args="deadbeef"))
|
||||
|
||||
assert "Couldn't find Dream change `deadbeef`." in out.content
|
||||
assert "Use `/dream-restore` to list recent versions" in out.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_log_before_first_run_is_clear() -> None:
|
||||
git = _FakeGit(initialized=False)
|
||||
|
||||
out = await cmd_dream_log(_make_ctx("/dream-log", git, last_dream_cursor=0))
|
||||
|
||||
assert "Dream has not run yet." in out.content
|
||||
assert "Run `/dream`" in out.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_restore_lists_versions_with_next_steps() -> None:
|
||||
commits = [
|
||||
CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00"),
|
||||
CommitInfo(sha="bbbb2222", message="dream: older", timestamp="2026-04-04 08:00"),
|
||||
]
|
||||
git = _FakeGit(commits=commits)
|
||||
|
||||
out = await cmd_dream_restore(_make_ctx("/dream-restore", git))
|
||||
|
||||
assert "## Dream Restore" in out.content
|
||||
assert "Choose a Dream memory version to restore." in out.content
|
||||
assert "`abcd1234` 2026-04-04 12:00 - dream: latest" in out.content
|
||||
assert "Preview a version with `/dream-log <sha>`" in out.content
|
||||
assert "Restore a version with `/dream-restore <sha>`." in out.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_restore_success_mentions_files_and_followup() -> None:
|
||||
commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00")
|
||||
diff = (
|
||||
"diff --git a/SOUL.md b/SOUL.md\n"
|
||||
"--- a/SOUL.md\n"
|
||||
"+++ b/SOUL.md\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
"diff --git a/memory/MEMORY.md b/memory/MEMORY.md\n"
|
||||
"--- a/memory/MEMORY.md\n"
|
||||
"+++ b/memory/MEMORY.md\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
)
|
||||
git = _FakeGit(
|
||||
diff_map={commit.sha: (commit, diff)},
|
||||
revert_result="eeee9999",
|
||||
)
|
||||
|
||||
out = await cmd_dream_restore(_make_ctx("/dream-restore abcd1234", git, args="abcd1234"))
|
||||
|
||||
assert "Restored Dream memory to the state before `abcd1234`." in out.content
|
||||
assert "- New safety commit: `eeee9999`" in out.content
|
||||
assert "- Restored files: `SOUL.md`, `memory/MEMORY.md`" in out.content
|
||||
assert "Use `/dream-log eeee9999` to inspect the restore diff." in out.content
|
||||
@@ -1,6 +1,18 @@
|
||||
import json
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.security.network import validate_url_target
|
||||
|
||||
|
||||
def _fake_resolve(host: str, results: list[str]):
|
||||
"""Return a getaddrinfo mock that maps the given host to fake IP results."""
|
||||
def _resolver(hostname, port, family=0, type_=0):
|
||||
if hostname == host:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0)) for ip in results]
|
||||
raise socket.gaierror(f"cannot resolve {hostname}")
|
||||
return _resolver
|
||||
|
||||
|
||||
def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path) -> None:
|
||||
@@ -126,3 +138,23 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch)
|
||||
assert result.exit_code == 0
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["channels"]["qq"]["msgFormat"] == "plain"
|
||||
|
||||
|
||||
def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None:
|
||||
whitelisted = tmp_path / "whitelisted.json"
|
||||
whitelisted.write_text(
|
||||
json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
defaulted = tmp_path / "defaulted.json"
|
||||
defaulted.write_text(json.dumps({}), encoding="utf-8")
|
||||
|
||||
load_config(whitelisted)
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, err = validate_url_target("http://ts.local/api")
|
||||
assert ok, err
|
||||
|
||||
load_config(defaulted)
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, _ = validate_url_target("http://ts.local/api")
|
||||
assert not ok
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from nanobot.config.schema import DreamConfig
|
||||
|
||||
|
||||
def test_dream_config_defaults_to_interval_hours() -> None:
|
||||
cfg = DreamConfig()
|
||||
|
||||
assert cfg.interval_h == 2
|
||||
assert cfg.cron is None
|
||||
|
||||
|
||||
def test_dream_config_builds_every_schedule_from_interval() -> None:
|
||||
cfg = DreamConfig(interval_h=3)
|
||||
|
||||
schedule = cfg.build_schedule("UTC")
|
||||
|
||||
assert schedule.kind == "every"
|
||||
assert schedule.every_ms == 3 * 3_600_000
|
||||
assert schedule.expr is None
|
||||
|
||||
|
||||
def test_dream_config_honors_legacy_cron_override() -> None:
|
||||
cfg = DreamConfig.model_validate({"cron": "0 */4 * * *"})
|
||||
|
||||
schedule = cfg.build_schedule("UTC")
|
||||
|
||||
assert schedule.kind == "cron"
|
||||
assert schedule.expr == "0 */4 * * *"
|
||||
assert schedule.tz == "UTC"
|
||||
assert cfg.describe_schedule() == "cron 0 */4 * * * (legacy)"
|
||||
|
||||
|
||||
def test_dream_config_dump_uses_interval_h_and_hides_legacy_cron() -> None:
|
||||
cfg = DreamConfig.model_validate({"intervalH": 5, "cron": "0 */4 * * *"})
|
||||
|
||||
dumped = cfg.model_dump(by_alias=True)
|
||||
|
||||
assert dumped["intervalH"] == 5
|
||||
assert "cron" not in dumped
|
||||
|
||||
|
||||
def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> None:
|
||||
cfg = DreamConfig.model_validate({"model": "openrouter/sonnet"})
|
||||
|
||||
dumped = cfg.model_dump(by_alias=True)
|
||||
|
||||
assert cfg.model_override == "openrouter/sonnet"
|
||||
assert dumped["modelOverride"] == "openrouter/sonnet"
|
||||
assert "model" not in dumped
|
||||
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import (
|
||||
_resolve_env_vars,
|
||||
load_config,
|
||||
resolve_config_env_vars,
|
||||
save_config,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
def test_replaces_string_value(self, monkeypatch):
|
||||
monkeypatch.setenv("MY_SECRET", "hunter2")
|
||||
assert _resolve_env_vars("${MY_SECRET}") == "hunter2"
|
||||
|
||||
def test_partial_replacement(self, monkeypatch):
|
||||
monkeypatch.setenv("HOST", "example.com")
|
||||
assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api"
|
||||
|
||||
def test_multiple_vars_in_one_string(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.setenv("PASS", "secret")
|
||||
assert _resolve_env_vars("${USER}:${PASS}") == "alice:secret"
|
||||
|
||||
def test_nested_dicts(self, monkeypatch):
|
||||
monkeypatch.setenv("TOKEN", "abc123")
|
||||
data = {"channels": {"telegram": {"token": "${TOKEN}"}}}
|
||||
result = _resolve_env_vars(data)
|
||||
assert result["channels"]["telegram"]["token"] == "abc123"
|
||||
|
||||
def test_lists(self, monkeypatch):
|
||||
monkeypatch.setenv("VAL", "x")
|
||||
assert _resolve_env_vars(["${VAL}", "plain"]) == ["x", "plain"]
|
||||
|
||||
def test_ignores_non_strings(self):
|
||||
assert _resolve_env_vars(42) == 42
|
||||
assert _resolve_env_vars(True) is True
|
||||
assert _resolve_env_vars(None) is None
|
||||
assert _resolve_env_vars(3.14) == 3.14
|
||||
|
||||
def test_plain_strings_unchanged(self):
|
||||
assert _resolve_env_vars("no vars here") == "no vars here"
|
||||
|
||||
def test_missing_var_raises(self):
|
||||
with pytest.raises(ValueError, match="DOES_NOT_EXIST"):
|
||||
_resolve_env_vars("${DOES_NOT_EXIST}")
|
||||
|
||||
|
||||
class TestResolveConfig:
|
||||
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{"providers": {"groq": {"apiKey": "${TEST_API_KEY}"}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
raw = load_config(config_path)
|
||||
assert raw.providers.groq.api_key == "${TEST_API_KEY}"
|
||||
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
assert resolved.providers.groq.api_key == "resolved-key"
|
||||
|
||||
def test_save_preserves_templates(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MY_TOKEN", "real-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{"channels": {"telegram": {"token": "${MY_TOKEN}"}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
raw = load_config(config_path)
|
||||
save_config(raw, config_path)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}"
|
||||
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
|
||||
|
||||
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
|
||||
@@ -114,6 +115,41 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None:
|
||||
assert loaded.state.run_history[0].status == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
||||
job = service.add_job(
|
||||
name="disabled",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
service.enable_job(job.id, enabled=False)
|
||||
|
||||
result = await service.run_job(job.id)
|
||||
|
||||
assert result is False
|
||||
assert service._running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job_preserves_running_service_state(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
||||
service._running = True
|
||||
job = service.add_job(
|
||||
name="manual",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
result = await service.run_job(job.id, force=True)
|
||||
|
||||
assert result is True
|
||||
assert service._running is True
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
@@ -141,3 +177,153 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||
assert called == []
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
def test_remove_job_refuses_system_jobs(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
service.register_system_job(CronJob(
|
||||
id="dream",
|
||||
name="dream",
|
||||
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
))
|
||||
|
||||
result = service.remove_job("dream")
|
||||
|
||||
assert result == "protected"
|
||||
assert service.get_job("dream") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_server_not_jobs(tmp_path):
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
called = []
|
||||
async def on_job(job):
|
||||
called.append(job.name)
|
||||
|
||||
service = CronService(store_path, on_job=on_job, max_sleep_ms=1000)
|
||||
await service.start()
|
||||
assert len(service.list_jobs()) == 0
|
||||
|
||||
service2 = CronService(tmp_path / "cron" / "jobs.json")
|
||||
service2.add_job(
|
||||
name="hist",
|
||||
schedule=CronSchedule(kind="every", every_ms=500),
|
||||
message="hello",
|
||||
)
|
||||
assert len(service.list_jobs()) == 1
|
||||
await asyncio.sleep(2)
|
||||
assert len(called) != 0
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subsecond_job_not_delayed_to_one_second(tmp_path):
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
called = []
|
||||
|
||||
async def on_job(job):
|
||||
called.append(job.name)
|
||||
|
||||
service = CronService(store_path, on_job=on_job, max_sleep_ms=5000)
|
||||
service.add_job(
|
||||
name="fast",
|
||||
schedule=CronSchedule(kind="every", every_ms=100),
|
||||
message="hello",
|
||||
)
|
||||
await service.start()
|
||||
try:
|
||||
await asyncio.sleep(0.35)
|
||||
assert called
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_service_picks_up_external_add(tmp_path):
|
||||
"""A running service should detect and execute a job added by another instance."""
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
called: list[str] = []
|
||||
|
||||
async def on_job(job):
|
||||
called.append(job.name)
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
service.add_job(
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=150),
|
||||
message="tick",
|
||||
)
|
||||
await service.start()
|
||||
try:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
external = CronService(store_path)
|
||||
external.add_job(
|
||||
name="external",
|
||||
schedule=CronSchedule(kind="every", every_ms=150),
|
||||
message="ping",
|
||||
)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
assert "external" in called
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_job_during_jobs_exec(tmp_path):
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
run_once = True
|
||||
|
||||
async def on_job(job):
|
||||
nonlocal run_once
|
||||
if run_once:
|
||||
service2 = CronService(store_path, on_job=lambda x: asyncio.sleep(0))
|
||||
service2.add_job(
|
||||
name="test",
|
||||
schedule=CronSchedule(kind="every", every_ms=150),
|
||||
message="tick",
|
||||
)
|
||||
run_once = False
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
service.add_job(
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(kind="every", every_ms=150),
|
||||
message="tick",
|
||||
)
|
||||
assert len(service.list_jobs()) == 1
|
||||
await service.start()
|
||||
try:
|
||||
await asyncio.sleep(3)
|
||||
jobs = service.list_jobs()
|
||||
assert len(jobs) == 2
|
||||
assert "test" in [j.name for j in jobs]
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_update_preserves_run_history_records(tmp_path):
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
||||
job = service.add_job(
|
||||
name="history",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
await service.run_job(job.id, force=True)
|
||||
|
||||
external = CronService(store_path)
|
||||
updated = external.enable_job(job.id, enabled=False)
|
||||
assert updated is not None
|
||||
|
||||
fresh = CronService(store_path)
|
||||
loaded = fresh.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.state.run_history
|
||||
assert loaded.state.run_history[0].status == "ok"
|
||||
|
||||
fresh._running = True
|
||||
fresh._save_store()
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJobState, CronSchedule
|
||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
|
||||
from tests.test_openai_api import pytest_plugins
|
||||
|
||||
|
||||
def _make_tool(tmp_path) -> CronTool:
|
||||
@@ -215,8 +218,10 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None:
|
||||
assert "Asia/Shanghai" in result
|
||||
|
||||
|
||||
def test_list_shows_last_run_state(tmp_path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_shows_last_run_state(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool._cron._running = True
|
||||
job = tool._cron.add_job(
|
||||
name="Stateful job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
@@ -232,9 +237,10 @@ def test_list_shows_last_run_state(tmp_path) -> None:
|
||||
assert "ok" in result
|
||||
assert "(UTC)" in result
|
||||
|
||||
|
||||
def test_list_shows_error_message(tmp_path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_shows_error_message(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool._cron._running = True
|
||||
job = tool._cron.add_job(
|
||||
name="Failed job",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||
@@ -262,11 +268,44 @@ def test_list_shows_next_run(tmp_path) -> None:
|
||||
assert "(UTC)" in result
|
||||
|
||||
|
||||
def test_list_includes_protected_dream_system_job_with_memory_purpose(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool._cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
name="dream",
|
||||
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
))
|
||||
|
||||
result = tool._list_jobs()
|
||||
|
||||
assert "- dream (id: dream, cron: 0 */2 * * * (UTC))" in result
|
||||
assert "Dream memory consolidation for long-term memory." in result
|
||||
assert "cannot be removed" in result
|
||||
|
||||
|
||||
def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool._cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
name="dream",
|
||||
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
))
|
||||
|
||||
result = tool._remove_job("dream")
|
||||
|
||||
assert "Cannot remove job `dream`." in result
|
||||
assert "Dream memory consolidation job for long-term memory" in result
|
||||
assert "cannot be removed" in result
|
||||
assert tool._cron.get_job("dream") is not None
|
||||
|
||||
|
||||
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context("telegram", "chat-1")
|
||||
|
||||
result = tool._add_job("Morning standup", None, "0 8 * * *", None, None)
|
||||
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
@@ -277,7 +316,7 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context("telegram", "chat-1")
|
||||
|
||||
result = tool._add_job("Morning reminder", None, None, None, "2026-03-25T08:00:00")
|
||||
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
@@ -285,6 +324,28 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
assert job.schedule.at_ms == expected
|
||||
|
||||
|
||||
def test_add_job_delivers_by_default(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context("telegram", "chat-1")
|
||||
|
||||
result = tool._add_job(None, "Morning standup", 60, None, None, None)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
assert job.payload.deliver is True
|
||||
|
||||
|
||||
def test_add_job_can_disable_delivery(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context("telegram", "chat-1")
|
||||
|
||||
result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
job = tool._cron.list_jobs()[0]
|
||||
assert job.payload.deliver is False
|
||||
|
||||
|
||||
def test_list_excludes_disabled_jobs(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
job = tool._cron.add_job(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for Anthropic provider thinking / reasoning_effort modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
|
||||
def _make_provider(model: str = "claude-sonnet-4-6") -> AnthropicProvider:
|
||||
with patch("anthropic.AsyncAnthropic"):
|
||||
return AnthropicProvider(api_key="sk-test", default_model=model)
|
||||
|
||||
|
||||
def _build(provider: AnthropicProvider, reasoning_effort: str | None, **overrides):
|
||||
defaults = dict(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=None,
|
||||
model=None,
|
||||
max_tokens=4096,
|
||||
temperature=0.7,
|
||||
reasoning_effort=reasoning_effort,
|
||||
tool_choice=None,
|
||||
supports_caching=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return provider._build_kwargs(**defaults)
|
||||
|
||||
|
||||
def test_adaptive_sets_type_adaptive() -> None:
|
||||
kw = _build(_make_provider(), "adaptive")
|
||||
assert kw["thinking"] == {"type": "adaptive"}
|
||||
|
||||
|
||||
def test_adaptive_forces_temperature_one() -> None:
|
||||
kw = _build(_make_provider(), "adaptive")
|
||||
assert kw["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_adaptive_does_not_inflate_max_tokens() -> None:
|
||||
kw = _build(_make_provider(), "adaptive", max_tokens=2048)
|
||||
assert kw["max_tokens"] == 2048
|
||||
|
||||
|
||||
def test_adaptive_no_budget_tokens() -> None:
|
||||
kw = _build(_make_provider(), "adaptive")
|
||||
assert "budget_tokens" not in kw["thinking"]
|
||||
|
||||
|
||||
def test_high_uses_enabled_with_budget() -> None:
|
||||
kw = _build(_make_provider(), "high", max_tokens=4096)
|
||||
assert kw["thinking"]["type"] == "enabled"
|
||||
assert kw["thinking"]["budget_tokens"] == max(8192, 4096)
|
||||
assert kw["max_tokens"] >= kw["thinking"]["budget_tokens"] + 4096
|
||||
|
||||
|
||||
def test_low_uses_small_budget() -> None:
|
||||
kw = _build(_make_provider(), "low")
|
||||
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 1024}
|
||||
|
||||
|
||||
def test_none_does_not_enable_thinking() -> None:
|
||||
kw = _build(_make_provider(), None)
|
||||
assert "thinking" not in kw
|
||||
assert kw["temperature"] == 0.7
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test Azure OpenAI provider implementation (updated for model-based deployment names)."""
|
||||
"""Test Azure OpenAI provider (Responses API via OpenAI SDK)."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,392 +8,401 @@ from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
|
||||
def test_azure_openai_provider_init():
|
||||
"""Test AzureOpenAIProvider initialization without deployment_name."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init & validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_creates_sdk_client():
|
||||
"""Provider creates an AsyncOpenAI client with correct base_url."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o-deployment",
|
||||
)
|
||||
|
||||
assert provider.api_key == "test-key"
|
||||
assert provider.api_base == "https://test-resource.openai.azure.com/"
|
||||
assert provider.default_model == "gpt-4o-deployment"
|
||||
assert provider.api_version == "2024-10-21"
|
||||
# SDK client base_url ends with /openai/v1/
|
||||
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_azure_openai_provider_init_validation():
|
||||
"""Test AzureOpenAIProvider initialization validation."""
|
||||
# Missing api_key
|
||||
def test_init_base_url_no_trailing_slash():
|
||||
"""Trailing slashes are normalised before building base_url."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://res.openai.azure.com",
|
||||
)
|
||||
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_init_base_url_with_trailing_slash():
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://res.openai.azure.com/",
|
||||
)
|
||||
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_init_validation_missing_key():
|
||||
with pytest.raises(ValueError, match="Azure OpenAI api_key is required"):
|
||||
AzureOpenAIProvider(api_key="", api_base="https://test.com")
|
||||
|
||||
# Missing api_base
|
||||
|
||||
|
||||
def test_init_validation_missing_base():
|
||||
with pytest.raises(ValueError, match="Azure OpenAI api_base is required"):
|
||||
AzureOpenAIProvider(api_key="test", api_base="")
|
||||
|
||||
|
||||
def test_build_chat_url():
|
||||
"""Test Azure OpenAI URL building with different deployment names."""
|
||||
def test_no_api_version_in_base_url():
|
||||
"""The /openai/v1/ path should NOT contain an api-version query param."""
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://res.openai.azure.com")
|
||||
base = str(provider._client.base_url)
|
||||
assert "api-version" not in base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _supports_temperature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_supports_temperature_standard_model():
|
||||
assert AzureOpenAIProvider._supports_temperature("gpt-4o") is True
|
||||
|
||||
|
||||
def test_supports_temperature_reasoning_model():
|
||||
assert AzureOpenAIProvider._supports_temperature("o3-mini") is False
|
||||
assert AzureOpenAIProvider._supports_temperature("gpt-5-chat") is False
|
||||
assert AzureOpenAIProvider._supports_temperature("o4-mini") is False
|
||||
|
||||
|
||||
def test_supports_temperature_with_reasoning_effort():
|
||||
assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_body — Responses API body construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_body_basic():
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
api_key="k", api_base="https://res.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
# Test various deployment names
|
||||
test_cases = [
|
||||
("gpt-4o-deployment", "https://test-resource.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21"),
|
||||
("gpt-35-turbo", "https://test-resource.openai.azure.com/openai/deployments/gpt-35-turbo/chat/completions?api-version=2024-10-21"),
|
||||
("custom-model", "https://test-resource.openai.azure.com/openai/deployments/custom-model/chat/completions?api-version=2024-10-21"),
|
||||
]
|
||||
|
||||
for deployment_name, expected_url in test_cases:
|
||||
url = provider._build_chat_url(deployment_name)
|
||||
assert url == expected_url
|
||||
messages = [{"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"}]
|
||||
body = provider._build_body(messages, None, None, 4096, 0.7, None, None)
|
||||
|
||||
|
||||
def test_build_chat_url_api_base_without_slash():
|
||||
"""Test URL building when api_base doesn't end with slash."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com", # No trailing slash
|
||||
default_model="gpt-4o",
|
||||
assert body["model"] == "gpt-4o"
|
||||
assert body["instructions"] == "You are helpful."
|
||||
assert body["temperature"] == 0.7
|
||||
assert body["max_output_tokens"] == 4096
|
||||
assert body["store"] is False
|
||||
assert "reasoning" not in body
|
||||
# input should contain the converted user message only (system extracted)
|
||||
assert any(
|
||||
item.get("role") == "user"
|
||||
for item in body["input"]
|
||||
)
|
||||
|
||||
url = provider._build_chat_url("test-deployment")
|
||||
expected = "https://test-resource.openai.azure.com/openai/deployments/test-deployment/chat/completions?api-version=2024-10-21"
|
||||
assert url == expected
|
||||
|
||||
|
||||
def test_build_headers():
|
||||
"""Test Azure OpenAI header building with api-key authentication."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-api-key-123",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
|
||||
headers = provider._build_headers()
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["api-key"] == "test-api-key-123" # Azure OpenAI specific header
|
||||
assert "x-session-affinity" in headers
|
||||
def test_build_body_max_tokens_minimum():
|
||||
"""max_output_tokens should never be less than 1."""
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||
body = provider._build_body([{"role": "user", "content": "x"}], None, None, 0, 0.7, None, None)
|
||||
assert body["max_output_tokens"] == 1
|
||||
|
||||
|
||||
def test_prepare_request_payload():
|
||||
"""Test request payload preparation with Azure OpenAI 2024-10-21 compliance."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8)
|
||||
|
||||
assert payload["messages"] == messages
|
||||
assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens
|
||||
assert payload["temperature"] == 0.8
|
||||
assert "tools" not in payload
|
||||
|
||||
# Test with tools
|
||||
def test_build_body_with_tools():
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||
tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}]
|
||||
payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools)
|
||||
assert payload_with_tools["tools"] == tools
|
||||
assert payload_with_tools["tool_choice"] == "auto"
|
||||
|
||||
# Test with reasoning_effort
|
||||
payload_with_reasoning = provider._prepare_request_payload(
|
||||
"gpt-5-chat", messages, reasoning_effort="medium"
|
||||
body = provider._build_body(
|
||||
[{"role": "user", "content": "weather?"}], tools, None, 4096, 0.7, None, None,
|
||||
)
|
||||
assert payload_with_reasoning["reasoning_effort"] == "medium"
|
||||
assert "temperature" not in payload_with_reasoning
|
||||
assert body["tools"] == [{"type": "function", "name": "get_weather", "description": "", "parameters": {}}]
|
||||
assert body["tool_choice"] == "auto"
|
||||
|
||||
|
||||
def test_prepare_request_payload_sanitizes_messages():
|
||||
"""Test Azure payload strips non-standard message keys before sending."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
def test_build_body_with_reasoning():
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-5-chat")
|
||||
body = provider._build_body(
|
||||
[{"role": "user", "content": "think"}], None, "gpt-5-chat", 4096, 0.7, "medium", None,
|
||||
)
|
||||
assert body["reasoning"] == {"effort": "medium"}
|
||||
assert "reasoning.encrypted_content" in body.get("include", [])
|
||||
# temperature omitted for reasoning models
|
||||
assert "temperature" not in body
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
|
||||
"reasoning_content": "hidden chain-of-thought",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"name": "x",
|
||||
"content": "ok",
|
||||
"extra_field": "should be removed",
|
||||
},
|
||||
]
|
||||
|
||||
payload = provider._prepare_request_payload("gpt-4o", messages)
|
||||
def test_build_body_image_conversion():
|
||||
"""image_url content blocks should be converted to input_image."""
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
|
||||
],
|
||||
}]
|
||||
body = provider._build_body(messages, None, None, 4096, 0.7, None, None)
|
||||
user_item = body["input"][0]
|
||||
content_types = [b["type"] for b in user_item["content"]]
|
||||
assert "input_text" in content_types
|
||||
assert "input_image" in content_types
|
||||
image_block = next(b for b in user_item["content"] if b["type"] == "input_image")
|
||||
assert image_block["image_url"] == "https://example.com/img.png"
|
||||
|
||||
assert payload["messages"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
|
||||
|
||||
def test_build_body_sanitizes_single_dict_content_block():
|
||||
"""Single content dicts should be preserved via shared message sanitization."""
|
||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": {"type": "text", "text": "Hi from dict content"},
|
||||
}]
|
||||
|
||||
body = provider._build_body(messages, None, None, 4096, 0.7, None, None)
|
||||
|
||||
assert body["input"][0]["content"] == [{"type": "input_text", "text": "Hi from dict content"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat() — non-streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sdk_response(
|
||||
content="Hello!", tool_calls=None, status="completed",
|
||||
usage=None,
|
||||
):
|
||||
"""Build a mock that quacks like an openai Response object."""
|
||||
resp = MagicMock()
|
||||
resp.model_dump = MagicMock(return_value={
|
||||
"output": [
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": content}]},
|
||||
*([{
|
||||
"type": "function_call",
|
||||
"call_id": tc["call_id"], "id": tc["id"],
|
||||
"name": tc["name"], "arguments": tc["arguments"],
|
||||
} for tc in (tool_calls or [])]),
|
||||
],
|
||||
"status": status,
|
||||
"usage": {
|
||||
"input_tokens": (usage or {}).get("input_tokens", 10),
|
||||
"output_tokens": (usage or {}).get("output_tokens", 5),
|
||||
"total_tokens": (usage or {}).get("total_tokens", 15),
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"name": "x",
|
||||
"content": "ok",
|
||||
},
|
||||
]
|
||||
})
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_success():
|
||||
"""Test successful chat request using model as deployment name."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o-deployment",
|
||||
api_key="test-key", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
# Mock response data
|
||||
mock_response_data = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello! How can I help you today?",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 18,
|
||||
"total_tokens": 30
|
||||
}
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = Mock(return_value=mock_response_data)
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value.__aenter__.return_value = mock_context
|
||||
|
||||
# Test with specific model (deployment name)
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = await provider.chat(messages, model="custom-deployment")
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert result.content == "Hello! How can I help you today?"
|
||||
assert result.finish_reason == "stop"
|
||||
assert result.usage["prompt_tokens"] == 12
|
||||
assert result.usage["completion_tokens"] == 18
|
||||
assert result.usage["total_tokens"] == 30
|
||||
|
||||
# Verify URL was built with the provided model as deployment name
|
||||
call_args = mock_context.post.call_args
|
||||
expected_url = "https://test-resource.openai.azure.com/openai/deployments/custom-deployment/chat/completions?api-version=2024-10-21"
|
||||
assert call_args[0][0] == expected_url
|
||||
mock_resp = _make_sdk_response(content="Hello!")
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await provider.chat([{"role": "user", "content": "Hi"}])
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert result.content == "Hello!"
|
||||
assert result.finish_reason == "stop"
|
||||
assert result.usage["prompt_tokens"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_uses_default_model_when_no_model_provided():
|
||||
"""Test that chat uses default_model when no model is specified."""
|
||||
async def test_chat_uses_default_model():
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="default-deployment",
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="my-deployment",
|
||||
)
|
||||
|
||||
mock_response_data = {
|
||||
"choices": [{
|
||||
"message": {"content": "Response", "role": "assistant"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = Mock(return_value=mock_response_data)
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value.__aenter__.return_value = mock_context
|
||||
|
||||
messages = [{"role": "user", "content": "Test"}]
|
||||
await provider.chat(messages) # No model specified
|
||||
|
||||
# Verify URL was built with default model as deployment name
|
||||
call_args = mock_context.post.call_args
|
||||
expected_url = "https://test-resource.openai.azure.com/openai/deployments/default-deployment/chat/completions?api-version=2024-10-21"
|
||||
assert call_args[0][0] == expected_url
|
||||
mock_resp = _make_sdk_response(content="ok")
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_resp)
|
||||
|
||||
await provider.chat([{"role": "user", "content": "test"}])
|
||||
|
||||
call_kwargs = provider._client.responses.create.call_args[1]
|
||||
assert call_kwargs["model"] == "my-deployment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_custom_model():
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
mock_resp = _make_sdk_response(content="ok")
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_resp)
|
||||
|
||||
await provider.chat([{"role": "user", "content": "test"}], model="custom-deploy")
|
||||
|
||||
call_kwargs = provider._client.responses.create.call_args[1]
|
||||
assert call_kwargs["model"] == "custom-deploy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_tool_calls():
|
||||
"""Test chat request with tool calls in response."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
# Mock response with tool calls
|
||||
mock_response_data = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_12345",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "San Francisco"}'
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
mock_resp = _make_sdk_response(
|
||||
content=None,
|
||||
tool_calls=[{
|
||||
"call_id": "call_123", "id": "fc_1",
|
||||
"name": "get_weather", "arguments": '{"location": "SF"}',
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 15,
|
||||
"total_tokens": 35
|
||||
}
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = Mock(return_value=mock_response_data)
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value.__aenter__.return_value = mock_context
|
||||
|
||||
messages = [{"role": "user", "content": "What's the weather?"}]
|
||||
tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}]
|
||||
result = await provider.chat(messages, tools=tools, model="weather-model")
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert result.content is None
|
||||
assert result.finish_reason == "tool_calls"
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "get_weather"
|
||||
assert result.tool_calls[0].arguments == {"location": "San Francisco"}
|
||||
)
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await provider.chat(
|
||||
[{"role": "user", "content": "Weather?"}],
|
||||
tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}],
|
||||
)
|
||||
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "get_weather"
|
||||
assert result.tool_calls[0].arguments == {"location": "SF"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_api_error():
|
||||
"""Test chat request API error handling."""
|
||||
async def test_chat_error_handling():
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.text = "Invalid authentication credentials"
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value.__aenter__.return_value = mock_context
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = await provider.chat(messages)
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert "Azure OpenAI API Error 401" in result.content
|
||||
assert "Invalid authentication credentials" in result.content
|
||||
assert result.finish_reason == "error"
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
|
||||
result = await provider.chat([{"role": "user", "content": "Hi"}])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_connection_error():
|
||||
"""Test chat request connection error handling."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_context = AsyncMock()
|
||||
mock_context.post = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
mock_client.return_value.__aenter__.return_value = mock_context
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = await provider.chat(messages)
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content
|
||||
assert result.finish_reason == "error"
|
||||
|
||||
|
||||
def test_parse_response_malformed():
|
||||
"""Test response parsing with malformed data."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
|
||||
# Test with missing choices
|
||||
malformed_response = {"usage": {"prompt_tokens": 10}}
|
||||
result = provider._parse_response(malformed_response)
|
||||
|
||||
assert isinstance(result, LLMResponse)
|
||||
assert "Error parsing Azure OpenAI response" in result.content
|
||||
assert "Connection failed" in result.content
|
||||
assert result.finish_reason == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_reasoning_param_format():
|
||||
"""reasoning_effort should be sent as reasoning={effort: ...} not a flat string."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-5-chat",
|
||||
)
|
||||
mock_resp = _make_sdk_response(content="thought")
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_resp)
|
||||
|
||||
await provider.chat(
|
||||
[{"role": "user", "content": "think"}], reasoning_effort="medium",
|
||||
)
|
||||
|
||||
call_kwargs = provider._client.responses.create.call_args[1]
|
||||
assert call_kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert "reasoning_effort" not in call_kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat_stream()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_success():
|
||||
"""Streaming should call on_content_delta and return combined response."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
# Build mock SDK stream events
|
||||
events = []
|
||||
ev1 = MagicMock(type="response.output_text.delta", delta="Hello")
|
||||
ev2 = MagicMock(type="response.output_text.delta", delta=" world")
|
||||
resp_obj = MagicMock(status="completed")
|
||||
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
||||
events = [ev1, ev2, ev3]
|
||||
|
||||
async def mock_stream():
|
||||
for e in events:
|
||||
yield e
|
||||
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_stream())
|
||||
|
||||
deltas: list[str] = []
|
||||
|
||||
async def on_delta(text: str) -> None:
|
||||
deltas.append(text)
|
||||
|
||||
result = await provider.chat_stream(
|
||||
[{"role": "user", "content": "Hi"}], on_content_delta=on_delta,
|
||||
)
|
||||
|
||||
assert result.content == "Hello world"
|
||||
assert result.finish_reason == "stop"
|
||||
assert deltas == ["Hello", " world"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_with_tool_calls():
|
||||
"""Streaming tool calls should be accumulated correctly."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
|
||||
item_added = MagicMock(type="function_call", call_id="call_1", id="fc_1", arguments="")
|
||||
item_added.name = "get_weather"
|
||||
ev_added = MagicMock(type="response.output_item.added", item=item_added)
|
||||
ev_args_delta = MagicMock(type="response.function_call_arguments.delta", call_id="call_1", delta='{"loc')
|
||||
ev_args_done = MagicMock(
|
||||
type="response.function_call_arguments.done",
|
||||
call_id="call_1", arguments='{"location":"SF"}',
|
||||
)
|
||||
item_done = MagicMock(
|
||||
type="function_call", call_id="call_1", id="fc_1",
|
||||
arguments='{"location":"SF"}',
|
||||
)
|
||||
item_done.name = "get_weather"
|
||||
ev_item_done = MagicMock(type="response.output_item.done", item=item_done)
|
||||
resp_obj = MagicMock(status="completed")
|
||||
ev_completed = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def mock_stream():
|
||||
for e in [ev_added, ev_args_delta, ev_args_done, ev_item_done, ev_completed]:
|
||||
yield e
|
||||
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(return_value=mock_stream())
|
||||
|
||||
result = await provider.chat_stream(
|
||||
[{"role": "user", "content": "weather?"}],
|
||||
tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {}}}],
|
||||
)
|
||||
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "get_weather"
|
||||
assert result.tool_calls[0].arguments == {"location": "SF"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_error():
|
||||
"""Streaming should return error when SDK raises."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="k", api_base="https://test.openai.azure.com", default_model="gpt-4o",
|
||||
)
|
||||
provider._client.responses = MagicMock()
|
||||
provider._client.responses.create = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
|
||||
result = await provider.chat_stream([{"role": "user", "content": "Hi"}])
|
||||
|
||||
assert "Connection failed" in result.content
|
||||
assert result.finish_reason == "error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_default_model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_default_model():
|
||||
"""Test get_default_model method."""
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="my-custom-deployment",
|
||||
api_key="k", api_base="https://r.com", default_model="my-deploy",
|
||||
)
|
||||
|
||||
assert provider.get_default_model() == "my-custom-deployment"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run basic tests
|
||||
print("Running basic Azure OpenAI provider tests...")
|
||||
|
||||
# Test initialization
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://test-resource.openai.azure.com",
|
||||
default_model="gpt-4o-deployment",
|
||||
)
|
||||
print("✅ Provider initialization successful")
|
||||
|
||||
# Test URL building
|
||||
url = provider._build_chat_url("my-deployment")
|
||||
expected = "https://test-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21"
|
||||
assert url == expected
|
||||
print("✅ URL building works correctly")
|
||||
|
||||
# Test headers
|
||||
headers = provider._build_headers()
|
||||
assert headers["api-key"] == "test-key"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
print("✅ Header building works correctly")
|
||||
|
||||
# Test payload preparation
|
||||
messages = [{"role": "user", "content": "Test"}]
|
||||
payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000)
|
||||
assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format
|
||||
print("✅ Payload preparation works correctly")
|
||||
|
||||
print("✅ All basic tests passed! Updated test file is working correctly.")
|
||||
assert provider.get_default_model() == "my-deploy"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for cached token extraction from OpenAI-compatible providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
class FakeUsage:
|
||||
"""Mimics an OpenAI SDK usage object (has attributes, not dict keys)."""
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
class FakePromptDetails:
|
||||
"""Mimics prompt_tokens_details sub-object."""
|
||||
def __init__(self, cached_tokens=0):
|
||||
self.cached_tokens = cached_tokens
|
||||
|
||||
|
||||
class _FakeSpec:
|
||||
supports_prompt_caching = False
|
||||
model_id_prefix = None
|
||||
strip_model_prefix = False
|
||||
max_completion_tokens = False
|
||||
reasoning_effort = None
|
||||
|
||||
|
||||
def _provider():
|
||||
from unittest.mock import MagicMock
|
||||
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
|
||||
p.client = MagicMock()
|
||||
p.spec = _FakeSpec()
|
||||
return p
|
||||
|
||||
|
||||
# Minimal valid choice so _parse reaches _extract_usage.
|
||||
_DICT_CHOICE = {"message": {"content": "Hello"}}
|
||||
|
||||
class _FakeMessage:
|
||||
content = "Hello"
|
||||
tool_calls = None
|
||||
|
||||
|
||||
class _FakeChoice:
|
||||
message = _FakeMessage()
|
||||
finish_reason = "stop"
|
||||
|
||||
|
||||
# --- dict-based response (raw JSON / mapping) ---
|
||||
|
||||
def test_extract_usage_openai_cached_tokens_dict():
|
||||
"""prompt_tokens_details.cached_tokens from a dict response."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 300,
|
||||
"total_tokens": 2300,
|
||||
"prompt_tokens_details": {"cached_tokens": 1200},
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 1200
|
||||
assert result.usage["prompt_tokens"] == 2000
|
||||
|
||||
|
||||
def test_extract_usage_deepseek_cached_tokens_dict():
|
||||
"""prompt_cache_hit_tokens from a DeepSeek dict response."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 1500,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 1700,
|
||||
"prompt_cache_hit_tokens": 1200,
|
||||
"prompt_cache_miss_tokens": 300,
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 1200
|
||||
|
||||
|
||||
def test_extract_usage_no_cached_tokens_dict():
|
||||
"""Response without any cache fields -> no cached_tokens key."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 1200,
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert "cached_tokens" not in result.usage
|
||||
|
||||
|
||||
def test_extract_usage_openai_cached_zero_dict():
|
||||
"""cached_tokens=0 should NOT be included (same as existing fields)."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 300,
|
||||
"total_tokens": 2300,
|
||||
"prompt_tokens_details": {"cached_tokens": 0},
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert "cached_tokens" not in result.usage
|
||||
|
||||
|
||||
# --- object-based response (OpenAI SDK Pydantic model) ---
|
||||
|
||||
def test_extract_usage_openai_cached_tokens_obj():
|
||||
"""prompt_tokens_details.cached_tokens from an SDK object response."""
|
||||
p = _provider()
|
||||
usage_obj = FakeUsage(
|
||||
prompt_tokens=2000,
|
||||
completion_tokens=300,
|
||||
total_tokens=2300,
|
||||
prompt_tokens_details=FakePromptDetails(cached_tokens=1200),
|
||||
)
|
||||
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 1200
|
||||
|
||||
|
||||
def test_extract_usage_deepseek_cached_tokens_obj():
|
||||
"""prompt_cache_hit_tokens from a DeepSeek SDK object response."""
|
||||
p = _provider()
|
||||
usage_obj = FakeUsage(
|
||||
prompt_tokens=1500,
|
||||
completion_tokens=200,
|
||||
total_tokens=1700,
|
||||
prompt_cache_hit_tokens=1200,
|
||||
)
|
||||
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 1200
|
||||
|
||||
|
||||
def test_extract_usage_stepfun_top_level_cached_tokens_dict():
|
||||
"""StepFun/Moonshot: usage.cached_tokens at top level (not nested)."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 591,
|
||||
"completion_tokens": 120,
|
||||
"total_tokens": 711,
|
||||
"cached_tokens": 512,
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 512
|
||||
|
||||
|
||||
def test_extract_usage_stepfun_top_level_cached_tokens_obj():
|
||||
"""StepFun/Moonshot: usage.cached_tokens as SDK object attribute."""
|
||||
p = _provider()
|
||||
usage_obj = FakeUsage(
|
||||
prompt_tokens=591,
|
||||
completion_tokens=120,
|
||||
total_tokens=711,
|
||||
cached_tokens=512,
|
||||
)
|
||||
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 512
|
||||
|
||||
|
||||
def test_extract_usage_priority_nested_over_top_level_dict():
|
||||
"""When both nested and top-level cached_tokens exist, nested wins."""
|
||||
p = _provider()
|
||||
response = {
|
||||
"choices": [_DICT_CHOICE],
|
||||
"usage": {
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 300,
|
||||
"total_tokens": 2300,
|
||||
"prompt_tokens_details": {"cached_tokens": 100},
|
||||
"cached_tokens": 500,
|
||||
}
|
||||
}
|
||||
result = p._parse(response)
|
||||
assert result.usage["cached_tokens"] == 100
|
||||
|
||||
|
||||
def test_anthropic_maps_cache_fields_to_cached_tokens():
|
||||
"""Anthropic's cache_read_input_tokens should map to cached_tokens."""
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
usage_obj = FakeUsage(
|
||||
input_tokens=800,
|
||||
output_tokens=200,
|
||||
cache_creation_input_tokens=300,
|
||||
cache_read_input_tokens=1200,
|
||||
)
|
||||
content_block = FakeUsage(type="text", text="hello")
|
||||
response = FakeUsage(
|
||||
id="msg_1",
|
||||
type="message",
|
||||
stop_reason="end_turn",
|
||||
content=[content_block],
|
||||
usage=usage_obj,
|
||||
)
|
||||
result = AnthropicProvider._parse_response(response)
|
||||
assert result.usage["cached_tokens"] == 1200
|
||||
assert result.usage["prompt_tokens"] == 2300
|
||||
assert result.usage["total_tokens"] == 2500
|
||||
assert result.usage["cache_creation_input_tokens"] == 300
|
||||
|
||||
|
||||
def test_anthropic_no_cache_fields():
|
||||
"""Anthropic response without cache fields should not have cached_tokens."""
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
usage_obj = FakeUsage(input_tokens=800, output_tokens=200)
|
||||
content_block = FakeUsage(type="text", text="hello")
|
||||
response = FakeUsage(
|
||||
id="msg_1",
|
||||
type="message",
|
||||
stop_reason="end_turn",
|
||||
content=[content_block],
|
||||
usage=usage_obj,
|
||||
)
|
||||
result = AnthropicProvider._parse_response(response)
|
||||
assert "cached_tokens" not in result.usage
|
||||
@@ -8,8 +8,9 @@ Validates that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -53,6 +54,66 @@ def _fake_tool_call_response() -> SimpleNamespace:
|
||||
return SimpleNamespace(choices=[choice], usage=usage)
|
||||
|
||||
|
||||
def _fake_responses_response(content: str = "ok") -> MagicMock:
|
||||
"""Build a minimal Responses API response object."""
|
||||
resp = MagicMock()
|
||||
resp.model_dump.return_value = {
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
}],
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _fake_responses_stream(text: str = "ok"):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="response.output_text.delta", delta=text)
|
||||
yield SimpleNamespace(
|
||||
type="response.completed",
|
||||
response=SimpleNamespace(
|
||||
status="completed",
|
||||
usage=SimpleNamespace(input_tokens=10, output_tokens=5, total_tokens=15),
|
||||
output=[],
|
||||
),
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def _fake_chat_stream(text: str = "ok"):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=None, delta=SimpleNamespace(content=text, reasoning_content=None, tool_calls=None))],
|
||||
usage=None,
|
||||
)
|
||||
yield SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason="stop", delta=SimpleNamespace(content=None, reasoning_content=None, tool_calls=None))],
|
||||
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
class _FakeResponsesError(Exception):
|
||||
def __init__(self, status_code: int, text: str):
|
||||
super().__init__(text)
|
||||
self.status_code = status_code
|
||||
self.response = SimpleNamespace(status_code=status_code, text=text, headers={})
|
||||
|
||||
|
||||
class _StalledStream:
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
await asyncio.sleep(3600)
|
||||
raise StopAsyncIteration
|
||||
|
||||
|
||||
def test_openrouter_spec_is_gateway() -> None:
|
||||
spec = find_by_name("openrouter")
|
||||
assert spec is not None
|
||||
@@ -214,3 +275,357 @@ def test_openai_model_passthrough() -> None:
|
||||
spec=spec,
|
||||
)
|
||||
assert provider.get_default_model() == "gpt-4o"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_gpt5_uses_responses_api() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
||||
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-5-chat",
|
||||
)
|
||||
|
||||
assert result.content == "from responses"
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_not_awaited()
|
||||
call_kwargs = mock_responses.call_args.kwargs
|
||||
assert call_kwargs["model"] == "gpt-5-chat"
|
||||
assert call_kwargs["max_output_tokens"] == 4096
|
||||
assert "input" in call_kwargs
|
||||
assert "messages" not in call_kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_reasoning_prefers_responses_api() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
||||
mock_responses = AsyncMock(return_value=_fake_responses_response("reasoned"))
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-4o",
|
||||
spec=spec,
|
||||
)
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-4o",
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_not_awaited()
|
||||
call_kwargs = mock_responses.call_args.kwargs
|
||||
assert call_kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
||||
mock_responses = AsyncMock(return_value=_fake_responses_response())
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-4o",
|
||||
spec=spec,
|
||||
)
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
mock_chat.assert_awaited_once()
|
||||
mock_responses.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_gpt5_stays_on_chat_completions() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
||||
mock_responses = AsyncMock(return_value=_fake_responses_response())
|
||||
spec = find_by_name("openrouter")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-or-test-key",
|
||||
api_base="https://openrouter.ai/api/v1",
|
||||
default_model="openai/gpt-5",
|
||||
spec=spec,
|
||||
)
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="openai/gpt-5",
|
||||
)
|
||||
|
||||
mock_chat.assert_awaited_once()
|
||||
mock_responses.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_streaming_gpt5_uses_responses_api() -> None:
|
||||
mock_chat = AsyncMock(return_value=_StalledStream())
|
||||
mock_responses = AsyncMock(return_value=_fake_responses_stream("hi"))
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat_stream(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-5-chat",
|
||||
)
|
||||
|
||||
assert result.content == "hi"
|
||||
assert result.finish_reason == "stop"
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
|
||||
mock_responses = AsyncMock(side_effect=_FakeResponsesError(404, "Responses endpoint not supported"))
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-5-chat",
|
||||
)
|
||||
|
||||
assert result.content == "from chat"
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
|
||||
mock_responses = AsyncMock(
|
||||
side_effect=_FakeResponsesError(400, "Unknown parameter: max_output_tokens for Responses API")
|
||||
)
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat_stream(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-5-chat",
|
||||
)
|
||||
|
||||
assert result.content == "fallback stream"
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_openai_responses_rate_limit_does_not_fallback() -> None:
|
||||
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
|
||||
mock_responses = AsyncMock(side_effect=_FakeResponsesError(429, "rate limit"))
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_chat
|
||||
client_instance.responses.create = mock_responses
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-5-chat",
|
||||
)
|
||||
|
||||
assert result.finish_reason == "error"
|
||||
mock_responses.assert_awaited_once()
|
||||
mock_chat.assert_not_awaited()
|
||||
|
||||
|
||||
def test_openai_compat_supports_temperature_matches_reasoning_model_rules() -> None:
|
||||
assert OpenAICompatProvider._supports_temperature("gpt-4o") is True
|
||||
assert OpenAICompatProvider._supports_temperature("gpt-5-chat") is False
|
||||
assert OpenAICompatProvider._supports_temperature("o3-mini") is False
|
||||
assert OpenAICompatProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False
|
||||
|
||||
|
||||
def test_openai_compat_build_kwargs_uses_gpt5_safe_parameters() -> None:
|
||||
spec = find_by_name("openai")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-5-chat",
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=None,
|
||||
model="gpt-5-chat",
|
||||
max_tokens=4096,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["model"] == "gpt-5-chat"
|
||||
assert kwargs["max_completion_tokens"] == 4096
|
||||
assert "max_tokens" not in kwargs
|
||||
assert "temperature" not in kwargs
|
||||
|
||||
|
||||
def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
sanitized = provider._sanitize_messages([
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden",
|
||||
"extra_content": {"debug": True},
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
"extra_content": {"google": {"thought_signature": "sig"}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "thanks"},
|
||||
])
|
||||
|
||||
assert sanitized[1]["reasoning_content"] == "hidden"
|
||||
assert sanitized[1]["extra_content"] == {"debug": True}
|
||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0")
|
||||
mock_create = AsyncMock(return_value=_StalledStream())
|
||||
spec = find_by_name("openai")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
||||
client_instance = MockClient.return_value
|
||||
client_instance.chat.completions.create = mock_create
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-4o",
|
||||
spec=spec,
|
||||
)
|
||||
result = await provider.chat_stream(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
assert result.finish_reason == "error"
|
||||
assert result.content is not None
|
||||
assert "stream stalled" in result.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider-specific thinking parameters (extra_body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_kwargs_for(provider_name: str, model: str, reasoning_effort=None):
|
||||
spec = find_by_name(provider_name)
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
p = OpenAICompatProvider(api_key="k", default_model=model, spec=spec)
|
||||
return p._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None, model=model, max_tokens=1024, temperature=0.7,
|
||||
reasoning_effort=reasoning_effort, tool_choice=None,
|
||||
)
|
||||
|
||||
|
||||
def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
|
||||
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="medium")
|
||||
assert kw["extra_body"] == {"enable_thinking": True}
|
||||
|
||||
|
||||
def test_dashscope_thinking_disabled_for_minimal() -> None:
|
||||
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
|
||||
assert kw["extra_body"] == {"enable_thinking": False}
|
||||
|
||||
|
||||
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_volcengine_thinking_enabled() -> None:
|
||||
kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_byteplus_thinking_disabled_for_minimal() -> None:
|
||||
kw = _build_kwargs_for("byteplus", "doubao-seed-2-0-pro", reasoning_effort="minimal")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
|
||||
def test_byteplus_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
kw = _build_kwargs_for("byteplus", "doubao-seed-2-0-pro", reasoning_effort=None)
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_openai_no_thinking_extra_body() -> None:
|
||||
"""Non-thinking providers should never get extra_body for thinking."""
|
||||
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")
|
||||
assert "extra_body" not in kw
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Tests for the shared openai_responses converters and parsers."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.openai_responses.converters import (
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
convert_user_message,
|
||||
split_tool_call_id,
|
||||
)
|
||||
from nanobot.providers.openai_responses.parsing import (
|
||||
consume_sdk_stream,
|
||||
map_finish_reason,
|
||||
parse_response_output,
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# converters - split_tool_call_id
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestSplitToolCallId:
|
||||
def test_plain_id(self):
|
||||
assert split_tool_call_id("call_abc") == ("call_abc", None)
|
||||
|
||||
def test_compound_id(self):
|
||||
assert split_tool_call_id("call_abc|fc_1") == ("call_abc", "fc_1")
|
||||
|
||||
def test_compound_empty_item_id(self):
|
||||
assert split_tool_call_id("call_abc|") == ("call_abc", None)
|
||||
|
||||
def test_none(self):
|
||||
assert split_tool_call_id(None) == ("call_0", None)
|
||||
|
||||
def test_empty_string(self):
|
||||
assert split_tool_call_id("") == ("call_0", None)
|
||||
|
||||
def test_non_string(self):
|
||||
assert split_tool_call_id(42) == ("call_0", None)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# converters - convert_user_message
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestConvertUserMessage:
|
||||
def test_string_content(self):
|
||||
result = convert_user_message("hello")
|
||||
assert result == {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}
|
||||
|
||||
def test_text_block(self):
|
||||
result = convert_user_message([{"type": "text", "text": "hi"}])
|
||||
assert result["content"] == [{"type": "input_text", "text": "hi"}]
|
||||
|
||||
def test_image_url_block(self):
|
||||
result = convert_user_message([
|
||||
{"type": "image_url", "image_url": {"url": "https://img.example/a.png"}},
|
||||
])
|
||||
assert result["content"] == [
|
||||
{"type": "input_image", "image_url": "https://img.example/a.png", "detail": "auto"},
|
||||
]
|
||||
|
||||
def test_mixed_text_and_image(self):
|
||||
result = convert_user_message([
|
||||
{"type": "text", "text": "what's this?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://img.example/b.png"}},
|
||||
])
|
||||
assert len(result["content"]) == 2
|
||||
assert result["content"][0]["type"] == "input_text"
|
||||
assert result["content"][1]["type"] == "input_image"
|
||||
|
||||
def test_empty_list_falls_back(self):
|
||||
result = convert_user_message([])
|
||||
assert result["content"] == [{"type": "input_text", "text": ""}]
|
||||
|
||||
def test_none_falls_back(self):
|
||||
result = convert_user_message(None)
|
||||
assert result["content"] == [{"type": "input_text", "text": ""}]
|
||||
|
||||
def test_image_without_url_skipped(self):
|
||||
result = convert_user_message([{"type": "image_url", "image_url": {}}])
|
||||
assert result["content"] == [{"type": "input_text", "text": ""}]
|
||||
|
||||
def test_meta_fields_not_leaked(self):
|
||||
"""_meta on content blocks must never appear in converted output."""
|
||||
result = convert_user_message([
|
||||
{"type": "text", "text": "hi", "_meta": {"path": "/tmp/x"}},
|
||||
])
|
||||
assert "_meta" not in result["content"][0]
|
||||
|
||||
def test_non_dict_items_skipped(self):
|
||||
result = convert_user_message(["just a string", 42])
|
||||
assert result["content"] == [{"type": "input_text", "text": ""}]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# converters - convert_messages
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
def test_system_extracted_as_instructions(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
instructions, items = convert_messages(msgs)
|
||||
assert instructions == "You are helpful."
|
||||
assert len(items) == 1
|
||||
assert items[0]["role"] == "user"
|
||||
|
||||
def test_multiple_system_messages_last_wins(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": "first"},
|
||||
{"role": "system", "content": "second"},
|
||||
{"role": "user", "content": "x"},
|
||||
]
|
||||
instructions, _ = convert_messages(msgs)
|
||||
assert instructions == "second"
|
||||
|
||||
def test_user_message_converted(self):
|
||||
_, items = convert_messages([{"role": "user", "content": "hello"}])
|
||||
assert items[0]["role"] == "user"
|
||||
assert items[0]["content"][0]["type"] == "input_text"
|
||||
|
||||
def test_assistant_text_message(self):
|
||||
_, items = convert_messages([
|
||||
{"role": "assistant", "content": "I'll help"},
|
||||
])
|
||||
assert items[0]["type"] == "message"
|
||||
assert items[0]["role"] == "assistant"
|
||||
assert items[0]["content"][0]["type"] == "output_text"
|
||||
assert items[0]["content"][0]["text"] == "I'll help"
|
||||
|
||||
def test_assistant_empty_content_skipped(self):
|
||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||
assert len(items) == 0
|
||||
|
||||
def test_assistant_with_tool_calls(self):
|
||||
_, items = convert_messages([{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_abc|fc_1",
|
||||
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
||||
}],
|
||||
}])
|
||||
assert items[0]["type"] == "function_call"
|
||||
assert items[0]["call_id"] == "call_abc"
|
||||
assert items[0]["id"] == "fc_1"
|
||||
assert items[0]["name"] == "get_weather"
|
||||
|
||||
def test_assistant_with_tool_calls_no_id(self):
|
||||
"""Fallback IDs when tool_call.id is missing."""
|
||||
_, items = convert_messages([{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"function": {"name": "f1", "arguments": "{}"}}],
|
||||
}])
|
||||
assert items[0]["call_id"] == "call_0"
|
||||
assert items[0]["id"].startswith("fc_")
|
||||
|
||||
def test_tool_message(self):
|
||||
_, items = convert_messages([{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc",
|
||||
"content": "result text",
|
||||
}])
|
||||
assert items[0]["type"] == "function_call_output"
|
||||
assert items[0]["call_id"] == "call_abc"
|
||||
assert items[0]["output"] == "result text"
|
||||
|
||||
def test_tool_message_dict_content(self):
|
||||
_, items = convert_messages([{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": {"key": "value"},
|
||||
}])
|
||||
assert items[0]["output"] == '{"key": "value"}'
|
||||
|
||||
def test_non_standard_keys_not_leaked(self):
|
||||
"""Extra keys on messages must not appear in converted items."""
|
||||
_, items = convert_messages([{
|
||||
"role": "user",
|
||||
"content": "hi",
|
||||
"extra_field": "should vanish",
|
||||
"_meta": {"path": "/tmp"},
|
||||
}])
|
||||
item = items[0]
|
||||
assert "extra_field" not in str(item)
|
||||
assert "_meta" not in str(item)
|
||||
|
||||
def test_full_conversation_roundtrip(self):
|
||||
"""System + user + assistant(tool_call) + tool -> correct structure."""
|
||||
msgs = [
|
||||
{"role": "system", "content": "Be concise."},
|
||||
{"role": "user", "content": "Weather in SF?"},
|
||||
{
|
||||
"role": "assistant", "content": None,
|
||||
"tool_calls": [{
|
||||
"id": "c1|fc1",
|
||||
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
||||
}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": '{"temp":72}'},
|
||||
]
|
||||
instructions, items = convert_messages(msgs)
|
||||
assert instructions == "Be concise."
|
||||
assert len(items) == 3 # user, function_call, function_call_output
|
||||
assert items[0]["role"] == "user"
|
||||
assert items[1]["type"] == "function_call"
|
||||
assert items[2]["type"] == "function_call_output"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# converters - convert_tools
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestConvertTools:
|
||||
def test_standard_function_tool(self):
|
||||
tools = [{"type": "function", "function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
}}]
|
||||
result = convert_tools(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "function"
|
||||
assert result[0]["name"] == "get_weather"
|
||||
assert result[0]["description"] == "Get weather"
|
||||
assert "properties" in result[0]["parameters"]
|
||||
|
||||
def test_tool_without_name_skipped(self):
|
||||
tools = [{"type": "function", "function": {"parameters": {}}}]
|
||||
assert convert_tools(tools) == []
|
||||
|
||||
def test_tool_without_function_wrapper(self):
|
||||
"""Direct dict without type=function wrapper."""
|
||||
tools = [{"name": "f1", "description": "d", "parameters": {}}]
|
||||
result = convert_tools(tools)
|
||||
assert result[0]["name"] == "f1"
|
||||
|
||||
def test_missing_optional_fields_default(self):
|
||||
tools = [{"type": "function", "function": {"name": "f"}}]
|
||||
result = convert_tools(tools)
|
||||
assert result[0]["description"] == ""
|
||||
assert result[0]["parameters"] == {}
|
||||
|
||||
def test_multiple_tools(self):
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "a", "parameters": {}}},
|
||||
{"type": "function", "function": {"name": "b", "parameters": {}}},
|
||||
]
|
||||
assert len(convert_tools(tools)) == 2
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - map_finish_reason
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestMapFinishReason:
|
||||
def test_completed(self):
|
||||
assert map_finish_reason("completed") == "stop"
|
||||
|
||||
def test_incomplete(self):
|
||||
assert map_finish_reason("incomplete") == "length"
|
||||
|
||||
def test_failed(self):
|
||||
assert map_finish_reason("failed") == "error"
|
||||
|
||||
def test_cancelled(self):
|
||||
assert map_finish_reason("cancelled") == "error"
|
||||
|
||||
def test_none_defaults_to_stop(self):
|
||||
assert map_finish_reason(None) == "stop"
|
||||
|
||||
def test_unknown_defaults_to_stop(self):
|
||||
assert map_finish_reason("some_new_status") == "stop"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - parse_response_output
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestParseResponseOutput:
|
||||
def test_text_response(self):
|
||||
resp = {
|
||||
"output": [{"type": "message", "role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]}],
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
result = parse_response_output(resp)
|
||||
assert result.content == "Hello!"
|
||||
assert result.finish_reason == "stop"
|
||||
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
assert result.tool_calls == []
|
||||
|
||||
def test_tool_call_response(self):
|
||||
resp = {
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1", "id": "fc_1",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "SF"}',
|
||||
}],
|
||||
"status": "completed",
|
||||
"usage": {},
|
||||
}
|
||||
result = parse_response_output(resp)
|
||||
assert result.content is None
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "get_weather"
|
||||
assert result.tool_calls[0].arguments == {"city": "SF"}
|
||||
assert result.tool_calls[0].id == "call_1|fc_1"
|
||||
|
||||
def test_malformed_tool_arguments_logged(self):
|
||||
"""Malformed JSON arguments should log a warning and fallback."""
|
||||
resp = {
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"call_id": "c1", "id": "fc1",
|
||||
"name": "f", "arguments": "{bad json",
|
||||
}],
|
||||
"status": "completed", "usage": {},
|
||||
}
|
||||
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
|
||||
result = parse_response_output(resp)
|
||||
assert result.tool_calls[0].arguments == {"raw": "{bad json"}
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
|
||||
|
||||
def test_reasoning_content_extracted(self):
|
||||
resp = {
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [
|
||||
{"type": "summary_text", "text": "I think "},
|
||||
{"type": "summary_text", "text": "therefore I am."},
|
||||
]},
|
||||
{"type": "message", "role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "42"}]},
|
||||
],
|
||||
"status": "completed", "usage": {},
|
||||
}
|
||||
result = parse_response_output(resp)
|
||||
assert result.content == "42"
|
||||
assert result.reasoning_content == "I think therefore I am."
|
||||
|
||||
def test_empty_output(self):
|
||||
resp = {"output": [], "status": "completed", "usage": {}}
|
||||
result = parse_response_output(resp)
|
||||
assert result.content is None
|
||||
assert result.tool_calls == []
|
||||
|
||||
def test_incomplete_status(self):
|
||||
resp = {"output": [], "status": "incomplete", "usage": {}}
|
||||
result = parse_response_output(resp)
|
||||
assert result.finish_reason == "length"
|
||||
|
||||
def test_sdk_model_object(self):
|
||||
"""parse_response_output should handle SDK objects with model_dump()."""
|
||||
mock = MagicMock()
|
||||
mock.model_dump.return_value = {
|
||||
"output": [{"type": "message", "role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "sdk"}]}],
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
|
||||
}
|
||||
result = parse_response_output(mock)
|
||||
assert result.content == "sdk"
|
||||
assert result.usage["prompt_tokens"] == 1
|
||||
|
||||
def test_usage_maps_responses_api_keys(self):
|
||||
"""Responses API uses input_tokens/output_tokens, not prompt_tokens/completion_tokens."""
|
||||
resp = {
|
||||
"output": [],
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150},
|
||||
}
|
||||
result = parse_response_output(resp)
|
||||
assert result.usage["prompt_tokens"] == 100
|
||||
assert result.usage["completion_tokens"] == 50
|
||||
assert result.usage["total_tokens"] == 150
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - consume_sdk_stream
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class TestConsumeSdkStream:
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_stream(self):
|
||||
ev1 = MagicMock(type="response.output_text.delta", delta="Hello")
|
||||
ev2 = MagicMock(type="response.output_text.delta", delta=" world")
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2, ev3]:
|
||||
yield e
|
||||
|
||||
content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream())
|
||||
assert content == "Hello world"
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_content_delta_called(self):
|
||||
ev1 = MagicMock(type="response.output_text.delta", delta="hi")
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev2 = MagicMock(type="response.completed", response=resp_obj)
|
||||
deltas = []
|
||||
|
||||
async def cb(text):
|
||||
deltas.append(text)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2]:
|
||||
yield e
|
||||
|
||||
await consume_sdk_stream(stream(), on_content_delta=cb)
|
||||
assert deltas == ["hi"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_stream(self):
|
||||
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
||||
item_added.name = "get_weather"
|
||||
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
||||
ev2 = MagicMock(type="response.function_call_arguments.delta", call_id="c1", delta='{"ci')
|
||||
ev3 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments='{"city":"SF"}')
|
||||
item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments='{"city":"SF"}')
|
||||
item_done.name = "get_weather"
|
||||
ev4 = MagicMock(type="response.output_item.done", item=item_done)
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev5 = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2, ev3, ev4, ev5]:
|
||||
yield e
|
||||
|
||||
content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream())
|
||||
assert content == ""
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0].name == "get_weather"
|
||||
assert tool_calls[0].arguments == {"city": "SF"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_extracted(self):
|
||||
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
|
||||
resp_obj = MagicMock(status="completed", usage=usage_obj, output=[])
|
||||
ev = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def stream():
|
||||
yield ev
|
||||
|
||||
_, _, _, usage, _ = await consume_sdk_stream(stream())
|
||||
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_extracted(self):
|
||||
summary_item = MagicMock(type="summary_text", text="thinking...")
|
||||
reasoning_item = MagicMock(type="reasoning", summary=[summary_item])
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[reasoning_item])
|
||||
ev = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def stream():
|
||||
yield ev
|
||||
|
||||
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
||||
assert reasoning == "thinking..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_raises(self):
|
||||
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
||||
|
||||
async def stream():
|
||||
yield ev
|
||||
|
||||
with pytest.raises(RuntimeError, match="Response failed.*rate_limit_exceeded"):
|
||||
await consume_sdk_stream(stream())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_event_raises(self):
|
||||
ev = MagicMock(type="response.failed", error="server_error")
|
||||
|
||||
async def stream():
|
||||
yield ev
|
||||
|
||||
with pytest.raises(RuntimeError, match="Response failed.*server_error"):
|
||||
await consume_sdk_stream(stream())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_tool_args_logged(self):
|
||||
"""Malformed JSON in streaming tool args should log a warning."""
|
||||
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
||||
item_added.name = "f"
|
||||
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
||||
ev2 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments="{bad")
|
||||
item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="{bad")
|
||||
item_done.name = "f"
|
||||
ev3 = MagicMock(type="response.output_item.done", item=item_done)
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev4 = MagicMock(type="response.completed", response=resp_obj)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2, ev3, ev4]:
|
||||
yield e
|
||||
|
||||
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
|
||||
_, tool_calls, _, _, _ = await consume_sdk_stream(stream())
|
||||
assert tool_calls[0].arguments == {"raw": "{bad"}
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
def _openai_tools(*names: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": f"{name} tool",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
|
||||
|
||||
def _anthropic_tools(*names: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"description": f"{name} tool",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
|
||||
|
||||
def _marked_openai_tool_names(tools: list[dict[str, Any]] | None) -> list[str]:
|
||||
if not tools:
|
||||
return []
|
||||
marked: list[str] = []
|
||||
for tool in tools:
|
||||
if "cache_control" in tool:
|
||||
marked.append((tool.get("function") or {}).get("name", ""))
|
||||
return marked
|
||||
|
||||
|
||||
def _marked_anthropic_tool_names(tools: list[dict[str, Any]] | None) -> list[str]:
|
||||
if not tools:
|
||||
return []
|
||||
return [tool.get("name", "") for tool in tools if "cache_control" in tool]
|
||||
|
||||
|
||||
def test_openai_compat_marks_builtin_boundary_and_tail_tool() -> None:
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "assistant", "content": "assistant"},
|
||||
{"role": "user", "content": "user"},
|
||||
]
|
||||
_, marked_tools = OpenAICompatProvider._apply_cache_control(
|
||||
messages,
|
||||
_openai_tools("read_file", "write_file", "mcp_fs_ls", "mcp_git_status"),
|
||||
)
|
||||
assert _marked_openai_tool_names(marked_tools) == ["write_file", "mcp_git_status"]
|
||||
|
||||
|
||||
def test_anthropic_marks_builtin_boundary_and_tail_tool() -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
]
|
||||
_, _, marked_tools = AnthropicProvider._apply_cache_control(
|
||||
"system",
|
||||
messages,
|
||||
_anthropic_tools("read_file", "write_file", "mcp_fs_ls", "mcp_git_status"),
|
||||
)
|
||||
assert _marked_anthropic_tool_names(marked_tools) == ["write_file", "mcp_git_status"]
|
||||
|
||||
|
||||
def test_openai_compat_marks_only_tail_without_mcp() -> None:
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "assistant", "content": "assistant"},
|
||||
{"role": "user", "content": "user"},
|
||||
]
|
||||
_, marked_tools = OpenAICompatProvider._apply_cache_control(
|
||||
messages,
|
||||
_openai_tools("read_file", "write_file"),
|
||||
)
|
||||
assert _marked_openai_tool_names(marked_tools) == ["write_file"]
|
||||
@@ -0,0 +1,81 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
def _fake_response(
|
||||
*,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
text: str = "",
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
status_code=status_code,
|
||||
headers=headers or {},
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def test_openai_handle_error_extracts_structured_metadata() -> None:
|
||||
class FakeStatusError(Exception):
|
||||
pass
|
||||
|
||||
err = FakeStatusError("boom")
|
||||
err.status_code = 409
|
||||
err.response = _fake_response(
|
||||
status_code=409,
|
||||
headers={"retry-after-ms": "250", "x-should-retry": "false"},
|
||||
text='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}',
|
||||
)
|
||||
err.body = {"error": {"type": "rate_limit_exceeded", "code": "rate_limit_exceeded"}}
|
||||
|
||||
response = OpenAICompatProvider._handle_error(err)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_status_code == 409
|
||||
assert response.error_type == "rate_limit_exceeded"
|
||||
assert response.error_code == "rate_limit_exceeded"
|
||||
assert response.error_retry_after_s == 0.25
|
||||
assert response.error_should_retry is False
|
||||
|
||||
|
||||
def test_openai_handle_error_marks_timeout_kind() -> None:
|
||||
class FakeTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
response = OpenAICompatProvider._handle_error(FakeTimeoutError("timeout"))
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_kind == "timeout"
|
||||
|
||||
|
||||
def test_anthropic_handle_error_extracts_structured_metadata() -> None:
|
||||
class FakeStatusError(Exception):
|
||||
pass
|
||||
|
||||
err = FakeStatusError("boom")
|
||||
err.status_code = 408
|
||||
err.response = _fake_response(
|
||||
status_code=408,
|
||||
headers={"retry-after": "1.5", "x-should-retry": "true"},
|
||||
)
|
||||
err.body = {"type": "error", "error": {"type": "rate_limit_error"}}
|
||||
|
||||
response = AnthropicProvider._handle_error(err)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_status_code == 408
|
||||
assert response.error_type == "rate_limit_error"
|
||||
assert response.error_retry_after_s == 1.5
|
||||
assert response.error_should_retry is True
|
||||
|
||||
|
||||
def test_anthropic_handle_error_marks_connection_kind() -> None:
|
||||
class FakeConnectionError(Exception):
|
||||
pass
|
||||
|
||||
response = AnthropicProvider._handle_error(FakeConnectionError("connection"))
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_kind == "connection"
|
||||
@@ -211,3 +211,242 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
assert any("[image omitted]" in (b.get("text") or "") for b in content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(content="429 rate limit, retry after 7s", finish_reason="error"),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
progress: list[str] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
async def _progress(msg: str) -> None:
|
||||
progress.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
on_retry_wait=_progress,
|
||||
)
|
||||
|
||||
assert response.content == "ok"
|
||||
assert delays == [7.0]
|
||||
assert progress and "7s" in progress[0]
|
||||
|
||||
|
||||
def test_extract_retry_after_supports_common_provider_formats() -> None:
|
||||
assert LLMProvider._extract_retry_after('{"error":{"retry_after":20}}') == 20.0
|
||||
assert LLMProvider._extract_retry_after("Rate limit reached, please try again in 20s") == 20.0
|
||||
assert LLMProvider._extract_retry_after("retry-after: 20") == 20.0
|
||||
|
||||
|
||||
def test_extract_retry_after_from_headers_supports_numeric_and_http_date() -> None:
|
||||
assert LLMProvider._extract_retry_after_from_headers({"Retry-After": "20"}) == 20.0
|
||||
assert LLMProvider._extract_retry_after_from_headers({"retry-after": "20"}) == 20.0
|
||||
assert LLMProvider._extract_retry_after_from_headers(
|
||||
{"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"},
|
||||
) == 0.1
|
||||
|
||||
|
||||
def test_extract_retry_after_from_headers_supports_retry_after_ms() -> None:
|
||||
assert LLMProvider._extract_retry_after_from_headers({"retry-after-ms": "250"}) == 0.25
|
||||
assert LLMProvider._extract_retry_after_from_headers({"Retry-After-Ms": "1000"}) == 1.0
|
||||
assert LLMProvider._extract_retry_after_from_headers(
|
||||
{"retry-after-ms": "500", "retry-after": "10"},
|
||||
) == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_prefers_structured_retry_after_when_present(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(content="429 rate limit", finish_reason="error", retry_after=9.0),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert delays == [9.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_retries_structured_status_code_without_keyword(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content="request failed",
|
||||
finish_reason="error",
|
||||
error_status_code=409,
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert provider.calls == 2
|
||||
assert delays == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_stops_on_429_quota_exhausted(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content='{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}',
|
||||
finish_reason="error",
|
||||
error_status_code=429,
|
||||
error_type="insufficient_quota",
|
||||
error_code="insufficient_quota",
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert provider.calls == 1
|
||||
assert delays == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_retries_429_transient_rate_limit(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}',
|
||||
finish_reason="error",
|
||||
error_status_code=429,
|
||||
error_type="rate_limit_exceeded",
|
||||
error_code="rate_limit_exceeded",
|
||||
error_retry_after_s=0.2,
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert provider.calls == 2
|
||||
assert delays == [0.2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_retries_structured_timeout_kind(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content="request failed",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert provider.calls == 2
|
||||
assert delays == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_structured_should_retry_false_disables_retry(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content="429 rate limit",
|
||||
finish_reason="error",
|
||||
error_should_retry=False,
|
||||
),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert provider.calls == 1
|
||||
assert delays == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_prefers_structured_retry_after(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content="429 rate limit, retry after 99s",
|
||||
finish_reason="error",
|
||||
error_retry_after_s=0.2,
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert delays == [0.2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
*[LLMResponse(content="429 rate limit", finish_reason="error") for _ in range(10)],
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
retry_mode="persistent",
|
||||
)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.content == "429 rate limit"
|
||||
assert provider.calls == 10
|
||||
assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
def test_openai_compat_error_captures_retry_after_from_headers() -> None:
|
||||
err = Exception("boom")
|
||||
err.doc = None
|
||||
err.response = SimpleNamespace(
|
||||
text='{"error":{"message":"Rate limit exceeded"}}',
|
||||
headers={"Retry-After": "20"},
|
||||
)
|
||||
|
||||
response = OpenAICompatProvider._handle_error(err)
|
||||
|
||||
assert response.retry_after == 20.0
|
||||
|
||||
|
||||
def test_azure_openai_error_captures_retry_after_from_headers() -> None:
|
||||
err = Exception("boom")
|
||||
err.body = {"message": "Rate limit exceeded"}
|
||||
err.response = SimpleNamespace(
|
||||
text='{"error":{"message":"Rate limit exceeded"}}',
|
||||
headers={"Retry-After": "20"},
|
||||
)
|
||||
|
||||
response = AzureOpenAIProvider._handle_error(err)
|
||||
|
||||
assert response.retry_after == 20.0
|
||||
|
||||
|
||||
def test_anthropic_error_captures_retry_after_from_headers() -> None:
|
||||
err = Exception("boom")
|
||||
err.response = SimpleNamespace(
|
||||
headers={"Retry-After": "20"},
|
||||
)
|
||||
|
||||
response = AnthropicProvider._handle_error(err)
|
||||
|
||||
assert response.retry_after == 20.0
|
||||
@@ -0,0 +1,33 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
def test_openai_compat_disables_sdk_retries_by_default() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client:
|
||||
OpenAICompatProvider(api_key="sk-test", default_model="gpt-4o")
|
||||
|
||||
kwargs = mock_client.call_args.kwargs
|
||||
assert kwargs["max_retries"] == 0
|
||||
|
||||
|
||||
def test_anthropic_disables_sdk_retries_by_default() -> None:
|
||||
with patch("anthropic.AsyncAnthropic") as mock_client:
|
||||
AnthropicProvider(api_key="sk-test", default_model="claude-sonnet-4-5")
|
||||
|
||||
kwargs = mock_client.call_args.kwargs
|
||||
assert kwargs["max_retries"] == 0
|
||||
|
||||
|
||||
def test_azure_openai_disables_sdk_retries_by_default() -> None:
|
||||
with patch("nanobot.providers.azure_openai_provider.AsyncOpenAI") as mock_client:
|
||||
AzureOpenAIProvider(
|
||||
api_key="sk-test",
|
||||
api_base="https://example.openai.azure.com",
|
||||
default_model="gpt-4.1",
|
||||
)
|
||||
|
||||
kwargs = mock_client.call_args.kwargs
|
||||
assert kwargs["max_retries"] == 0
|
||||
@@ -11,6 +11,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.anthropic_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_compat_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False)
|
||||
|
||||
providers = importlib.import_module("nanobot.providers")
|
||||
@@ -18,6 +19,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
assert "nanobot.providers.anthropic_provider" not in sys.modules
|
||||
assert "nanobot.providers.openai_compat_provider" not in sys.modules
|
||||
assert "nanobot.providers.openai_codex_provider" not in sys.modules
|
||||
assert "nanobot.providers.github_copilot_provider" not in sys.modules
|
||||
assert "nanobot.providers.azure_openai_provider" not in sys.modules
|
||||
assert providers.__all__ == [
|
||||
"LLMProvider",
|
||||
@@ -25,6 +27,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
"AnthropicProvider",
|
||||
"OpenAICompatProvider",
|
||||
"OpenAICodexProvider",
|
||||
"GitHubCopilotProvider",
|
||||
"AzureOpenAIProvider",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for reasoning_content extraction in OpenAICompatProvider.
|
||||
|
||||
Covers non-streaming (_parse) and streaming (_parse_chunks) paths for
|
||||
providers that return a reasoning_content field (e.g. MiMo, DeepSeek-R1).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
# ── _parse: non-streaming ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_dict_extracts_reasoning_content() -> None:
|
||||
"""reasoning_content at message level is surfaced in LLMResponse."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "42",
|
||||
"reasoning_content": "Let me think step by step…",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "42"
|
||||
assert result.reasoning_content == "Let me think step by step…"
|
||||
|
||||
|
||||
def test_parse_dict_reasoning_content_none_when_absent() -> None:
|
||||
"""reasoning_content is None when the response doesn't include it."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {"content": "hello"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.reasoning_content is None
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming dict branch ─────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_chunks_dict_accumulates_reasoning_content() -> None:
|
||||
"""reasoning_content deltas in dict chunks are joined into one string."""
|
||||
chunks = [
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": None,
|
||||
"delta": {"content": None, "reasoning_content": "Step 1. "},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": None,
|
||||
"delta": {"content": None, "reasoning_content": "Step 2."},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"delta": {"content": "answer"},
|
||||
}],
|
||||
},
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.content == "answer"
|
||||
assert result.reasoning_content == "Step 1. Step 2."
|
||||
|
||||
|
||||
def test_parse_chunks_dict_reasoning_content_none_when_absent() -> None:
|
||||
"""reasoning_content is None when no chunk contains it."""
|
||||
chunks = [
|
||||
{"choices": [{"finish_reason": "stop", "delta": {"content": "hi"}}]},
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.content == "hi"
|
||||
assert result.reasoning_content is None
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming SDK-object branch ────────────────────────────
|
||||
|
||||
|
||||
def _make_reasoning_chunk(reasoning: str | None, content: str | None, finish: str | None):
|
||||
delta = SimpleNamespace(content=content, reasoning_content=reasoning, tool_calls=None)
|
||||
choice = SimpleNamespace(finish_reason=finish, delta=delta)
|
||||
return SimpleNamespace(choices=[choice], usage=None)
|
||||
|
||||
|
||||
def test_parse_chunks_sdk_accumulates_reasoning_content() -> None:
|
||||
"""reasoning_content on SDK delta objects is joined across chunks."""
|
||||
chunks = [
|
||||
_make_reasoning_chunk("Think… ", None, None),
|
||||
_make_reasoning_chunk("Done.", None, None),
|
||||
_make_reasoning_chunk(None, "result", "stop"),
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.content == "result"
|
||||
assert result.reasoning_content == "Think… Done."
|
||||
|
||||
|
||||
def test_parse_chunks_sdk_reasoning_content_none_when_absent() -> None:
|
||||
"""reasoning_content is None when SDK deltas carry no reasoning_content."""
|
||||
chunks = [_make_reasoning_chunk(None, "hello", "stop")]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.reasoning_content is None
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for StepFun Plan API reasoning field fallback in OpenAICompatProvider.
|
||||
|
||||
StepFun Plan API returns response content in the 'reasoning' field when
|
||||
the model is in thinking mode and 'content' is empty. This test module
|
||||
verifies the fallback logic for all code paths.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
# ── _parse: dict branch ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
||||
"""When content is None and reasoning exists, content falls back to reasoning."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"reasoning": "Let me think... The answer is 42.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "Let me think... The answer is 42."
|
||||
# reasoning_content should also be populated from reasoning
|
||||
assert result.reasoning_content == "Let me think... The answer is 42."
|
||||
|
||||
|
||||
def test_parse_dict_stepfun_reasoning_priority() -> None:
|
||||
"""reasoning_content field takes priority over reasoning when both present."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"reasoning": "informal thinking",
|
||||
"reasoning_content": "formal reasoning content",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "informal thinking"
|
||||
# reasoning_content uses the dedicated field, not reasoning
|
||||
assert result.reasoning_content == "formal reasoning content"
|
||||
|
||||
|
||||
# ── _parse: SDK object branch ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_sdk_message(content, reasoning=None, reasoning_content=None):
|
||||
"""Create a mock SDK message object."""
|
||||
msg = SimpleNamespace(content=content, tool_calls=None)
|
||||
if reasoning is not None:
|
||||
msg.reasoning = reasoning
|
||||
if reasoning_content is not None:
|
||||
msg.reasoning_content = reasoning_content
|
||||
return msg
|
||||
|
||||
|
||||
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
|
||||
"""SDK branch: content falls back to msg.reasoning when content is None."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
|
||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
||||
response = SimpleNamespace(choices=[choice], usage=None)
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "After analysis: result is 4."
|
||||
assert result.reasoning_content == "After analysis: result is 4."
|
||||
|
||||
|
||||
def test_parse_sdk_stepfun_reasoning_priority() -> None:
|
||||
"""reasoning_content field takes priority over reasoning in SDK branch."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
msg = _make_sdk_message(
|
||||
content=None,
|
||||
reasoning="thinking process",
|
||||
reasoning_content="formal reasoning"
|
||||
)
|
||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
||||
response = SimpleNamespace(choices=[choice], usage=None)
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "thinking process"
|
||||
assert result.reasoning_content == "formal reasoning"
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming dict branch ────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_chunks_dict_stepfun_reasoning_fallback() -> None:
|
||||
"""Streaming dict: reasoning field used when reasoning_content is absent."""
|
||||
chunks = [
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": None,
|
||||
"delta": {"content": None, "reasoning": "Thinking step 1... "},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": None,
|
||||
"delta": {"content": None, "reasoning": "step 2."},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"delta": {"content": "final answer"},
|
||||
}],
|
||||
},
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.content == "final answer"
|
||||
assert result.reasoning_content == "Thinking step 1... step 2."
|
||||
|
||||
|
||||
# ── Regression: normal models unaffected ────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_dict_normal_model_with_reasoning_content_unaffected() -> None:
|
||||
"""Models that use reasoning_content (e.g. DeepSeek-R1) are not affected."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "The answer is 42.",
|
||||
"reasoning_content": "Let me think step by step...",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "The answer is 42."
|
||||
assert result.reasoning_content == "Let me think step by step..."
|
||||
|
||||
|
||||
def test_parse_dict_standard_model_no_reasoning_unaffected() -> None:
|
||||
"""Standard models (no reasoning fields at all) work exactly as before."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {"content": "Hello!"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content == "Hello!"
|
||||
assert result.reasoning_content is None
|
||||
|
||||
|
||||
def test_parse_chunks_dict_reasoning_precedence() -> None:
|
||||
"""reasoning_content takes precedence over reasoning in dict chunks."""
|
||||
chunks = [
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": None,
|
||||
"delta": {
|
||||
"content": None,
|
||||
"reasoning_content": "formal: ",
|
||||
"reasoning": "informal: ",
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"delta": {"content": "result"},
|
||||
}],
|
||||
},
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.reasoning_content == "formal: "
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming SDK-object branch ─────────────────────────────
|
||||
|
||||
|
||||
def _make_sdk_chunk(reasoning_content=None, reasoning=None, content=None, finish=None):
|
||||
"""Create a mock SDK chunk object."""
|
||||
delta = SimpleNamespace(
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
reasoning=reasoning,
|
||||
tool_calls=None,
|
||||
)
|
||||
choice = SimpleNamespace(finish_reason=finish, delta=delta)
|
||||
return SimpleNamespace(choices=[choice], usage=None)
|
||||
|
||||
|
||||
def test_parse_chunks_sdk_stepfun_reasoning_fallback() -> None:
|
||||
"""SDK streaming: reasoning field used when reasoning_content is None."""
|
||||
chunks = [
|
||||
_make_sdk_chunk(reasoning="Thinking... ", content=None, finish=None),
|
||||
_make_sdk_chunk(reasoning=None, content="answer", finish="stop"),
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.content == "answer"
|
||||
assert result.reasoning_content == "Thinking... "
|
||||
|
||||
|
||||
def test_parse_chunks_sdk_reasoning_precedence() -> None:
|
||||
"""reasoning_content takes precedence over reasoning in SDK chunks."""
|
||||
chunks = [
|
||||
_make_sdk_chunk(reasoning_content="formal: ", reasoning="informal: ", content=None),
|
||||
_make_sdk_chunk(reasoning_content=None, reasoning=None, content="result", finish="stop"),
|
||||
]
|
||||
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.reasoning_content == "formal: "
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.security.network import contains_internal_url, validate_url_target
|
||||
from nanobot.security.network import configure_ssrf_whitelist, contains_internal_url, validate_url_target
|
||||
|
||||
|
||||
def _fake_resolve(host: str, results: list[str]):
|
||||
@@ -99,3 +99,47 @@ def test_allows_normal_curl():
|
||||
|
||||
def test_no_urls_returns_false():
|
||||
assert not contains_internal_url("echo hello && ls -la")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF whitelist — allow specific CIDR ranges (#2669)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_blocks_cgnat_by_default():
|
||||
"""100.64.0.0/10 (CGNAT / Tailscale) is blocked by default."""
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, _ = validate_url_target("http://ts.local/api")
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_whitelist_allows_cgnat():
|
||||
"""Whitelisting 100.64.0.0/10 lets Tailscale addresses through."""
|
||||
configure_ssrf_whitelist(["100.64.0.0/10"])
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, err = validate_url_target("http://ts.local/api")
|
||||
assert ok, f"Whitelisted CGNAT should be allowed, got: {err}"
|
||||
finally:
|
||||
configure_ssrf_whitelist([])
|
||||
|
||||
|
||||
def test_whitelist_does_not_affect_other_blocked():
|
||||
"""Whitelisting CGNAT must not unblock other private ranges."""
|
||||
configure_ssrf_whitelist(["100.64.0.0/10"])
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", ["10.0.0.1"])):
|
||||
ok, _ = validate_url_target("http://evil.com/secret")
|
||||
assert not ok
|
||||
finally:
|
||||
configure_ssrf_whitelist([])
|
||||
|
||||
|
||||
def test_whitelist_invalid_cidr_ignored():
|
||||
"""Invalid CIDR entries are silently skipped."""
|
||||
configure_ssrf_whitelist(["not-a-cidr", "100.64.0.0/10"])
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, _ = validate_url_target("http://ts.local/api")
|
||||
assert ok
|
||||
finally:
|
||||
configure_ssrf_whitelist([])
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for build_status_content cache hit rate display."""
|
||||
|
||||
from nanobot.utils.helpers import build_status_content
|
||||
|
||||
|
||||
def test_status_shows_cache_hit_rate():
|
||||
content = build_status_content(
|
||||
version="0.1.0",
|
||||
model="glm-4-plus",
|
||||
start_time=1000000.0,
|
||||
last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 1200},
|
||||
context_window_tokens=128000,
|
||||
session_msg_count=10,
|
||||
context_tokens_estimate=5000,
|
||||
)
|
||||
assert "60% cached" in content
|
||||
assert "2000 in / 300 out" in content
|
||||
|
||||
|
||||
def test_status_no_cache_info():
|
||||
"""Without cached_tokens, display should not show cache percentage."""
|
||||
content = build_status_content(
|
||||
version="0.1.0",
|
||||
model="glm-4-plus",
|
||||
start_time=1000000.0,
|
||||
last_usage={"prompt_tokens": 2000, "completion_tokens": 300},
|
||||
context_window_tokens=128000,
|
||||
session_msg_count=10,
|
||||
context_tokens_estimate=5000,
|
||||
)
|
||||
assert "cached" not in content.lower()
|
||||
assert "2000 in / 300 out" in content
|
||||
|
||||
|
||||
def test_status_zero_cached_tokens():
|
||||
"""cached_tokens=0 should not show cache percentage."""
|
||||
content = build_status_content(
|
||||
version="0.1.0",
|
||||
model="glm-4-plus",
|
||||
start_time=1000000.0,
|
||||
last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 0},
|
||||
context_window_tokens=128000,
|
||||
session_msg_count=10,
|
||||
context_tokens_estimate=5000,
|
||||
)
|
||||
assert "cached" not in content.lower()
|
||||
|
||||
|
||||
def test_status_100_percent_cached():
|
||||
content = build_status_content(
|
||||
version="0.1.0",
|
||||
model="glm-4-plus",
|
||||
start_time=1000000.0,
|
||||
last_usage={"prompt_tokens": 1000, "completion_tokens": 100, "cached_tokens": 1000},
|
||||
context_window_tokens=128000,
|
||||
session_msg_count=5,
|
||||
context_tokens_estimate=3000,
|
||||
)
|
||||
assert "100% cached" in content
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for the Nanobot programmatic facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.nanobot import Nanobot, RunResult
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
||||
data = {
|
||||
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
|
||||
"agents": {"defaults": {"model": "openai/gpt-4.1"}},
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps(data))
|
||||
return config_path
|
||||
|
||||
|
||||
def test_from_config_missing_file():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
Nanobot.from_config("/nonexistent/config.json")
|
||||
|
||||
|
||||
def test_from_config_creates_instance(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
assert bot._loop is not None
|
||||
assert bot._loop.workspace == tmp_path
|
||||
|
||||
|
||||
def test_from_config_default_path():
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
with patch("nanobot.config.loader.load_config") as mock_load, \
|
||||
patch("nanobot.nanobot._make_provider") as mock_prov:
|
||||
mock_load.return_value = Config()
|
||||
mock_prov.return_value = MagicMock()
|
||||
mock_prov.return_value.get_default_model.return_value = "test"
|
||||
mock_prov.return_value.generation.max_tokens = 4096
|
||||
Nanobot.from_config()
|
||||
mock_load.assert_called_once_with(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_returns_result(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
mock_response = OutboundMessage(
|
||||
channel="cli", chat_id="direct", content="Hello back!"
|
||||
)
|
||||
bot._loop.process_direct = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await bot.run("hi")
|
||||
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.content == "Hello back!"
|
||||
bot._loop.process_direct.assert_awaited_once_with("hi", session_key="sdk:default")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_hooks(tmp_path):
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
class TestHook(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
pass
|
||||
|
||||
mock_response = OutboundMessage(
|
||||
channel="cli", chat_id="direct", content="done"
|
||||
)
|
||||
bot._loop.process_direct = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await bot.run("hi", hooks=[TestHook()])
|
||||
|
||||
assert result.content == "done"
|
||||
assert bot._loop._extra_hooks == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_hooks_restored_on_error(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
from nanobot.agent.hook import AgentHook
|
||||
|
||||
bot._loop.process_direct = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
original_hooks = bot._loop._extra_hooks
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await bot.run("hi", hooks=[AgentHook()])
|
||||
|
||||
assert bot._loop._extra_hooks is original_hooks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_none_response(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
bot._loop.process_direct = AsyncMock(return_value=None)
|
||||
|
||||
result = await bot.run("hi")
|
||||
assert result.content == ""
|
||||
|
||||
|
||||
def test_workspace_override(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
custom_ws = tmp_path / "custom_workspace"
|
||||
custom_ws.mkdir()
|
||||
|
||||
bot = Nanobot.from_config(config_path, workspace=custom_ws)
|
||||
assert bot._loop.workspace == custom_ws
|
||||
|
||||
|
||||
def test_sdk_make_provider_uses_github_copilot_backend():
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.nanobot import _make_provider
|
||||
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "github-copilot",
|
||||
"model": "github-copilot/gpt-4.1",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = _make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_custom_session_key(tmp_path):
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
mock_response = OutboundMessage(
|
||||
channel="cli", chat_id="direct", content="ok"
|
||||
)
|
||||
bot._loop.process_direct = AsyncMock(return_value=mock_response)
|
||||
|
||||
await bot.run("hi", session_key="user-alice")
|
||||
bot._loop.process_direct.assert_awaited_once_with("hi", session_key="user-alice")
|
||||
|
||||
|
||||
def test_import_from_top_level():
|
||||
from nanobot import Nanobot as N, RunResult as R
|
||||
assert N is Nanobot
|
||||
assert R is RunResult
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Focused tests for the fixed-session OpenAI-compatible API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from nanobot.api.server import (
|
||||
API_CHAT_ID,
|
||||
API_SESSION_KEY,
|
||||
_chat_completion_response,
|
||||
_error_json,
|
||||
create_app,
|
||||
handle_chat_completions,
|
||||
)
|
||||
|
||||
try:
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
HAS_AIOHTTP = True
|
||||
except ImportError:
|
||||
HAS_AIOHTTP = False
|
||||
|
||||
pytest_plugins = ("pytest_asyncio",)
|
||||
|
||||
|
||||
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent = MagicMock()
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
return _make_mock_agent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(mock_agent):
|
||||
return create_app(mock_agent, model_name="test-model", request_timeout=10.0)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def aiohttp_client():
|
||||
clients: list[TestClient] = []
|
||||
|
||||
async def _make_client(app):
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
clients.append(client)
|
||||
return client
|
||||
|
||||
try:
|
||||
yield _make_client
|
||||
finally:
|
||||
for client in clients:
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_error_json() -> None:
|
||||
resp = _error_json(400, "bad request")
|
||||
assert resp.status == 400
|
||||
body = json.loads(resp.body)
|
||||
assert body["error"]["message"] == "bad request"
|
||||
assert body["error"]["code"] == 400
|
||||
|
||||
|
||||
def test_chat_completion_response() -> None:
|
||||
result = _chat_completion_response("hello world", "test-model")
|
||||
assert result["object"] == "chat.completion"
|
||||
assert result["model"] == "test-model"
|
||||
assert result["choices"][0]["message"]["content"] == "hello world"
|
||||
assert result["choices"][0]["finish_reason"] == "stop"
|
||||
assert result["id"].startswith("chatcmpl-")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_messages_returns_400(aiohttp_client, app) -> None:
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post("/v1/chat/completions", json={"model": "test"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "system", "content": "you are a bot"}]},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_true_returns_400(aiohttp_client, app) -> None:
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hello"}], "stream": True},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert "stream" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_mismatch_returns_400() -> None:
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"model": "other-model",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
}
|
||||
)
|
||||
request.app = {
|
||||
"agent_loop": _make_mock_agent(),
|
||||
"model_name": "test-model",
|
||||
"request_timeout": 10.0,
|
||||
"session_lock": asyncio.Lock(),
|
||||
}
|
||||
|
||||
resp = await handle_chat_completions(request)
|
||||
assert resp.status == 400
|
||||
body = json.loads(resp.body)
|
||||
assert "test-model" in body["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_user_message_required() -> None:
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "previous reply"},
|
||||
],
|
||||
}
|
||||
)
|
||||
request.app = {
|
||||
"agent_loop": _make_mock_agent(),
|
||||
"model_name": "test-model",
|
||||
"request_timeout": 10.0,
|
||||
"session_lock": asyncio.Lock(),
|
||||
}
|
||||
|
||||
resp = await handle_chat_completions(request)
|
||||
assert resp.status == 400
|
||||
body = json.loads(resp.body)
|
||||
assert "single user message" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_user_message_must_have_user_role() -> None:
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"messages": [{"role": "system", "content": "you are a bot"}],
|
||||
}
|
||||
)
|
||||
request.app = {
|
||||
"agent_loop": _make_mock_agent(),
|
||||
"model_name": "test-model",
|
||||
"request_timeout": 10.0,
|
||||
"session_lock": asyncio.Lock(),
|
||||
}
|
||||
|
||||
resp = await handle_chat_completions(request)
|
||||
assert resp.status == 400
|
||||
body = json.loads(resp.body)
|
||||
assert "single user message" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None:
|
||||
app = create_app(mock_agent, model_name="test-model")
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["choices"][0]["message"]["content"] == "mock response"
|
||||
assert body["model"] == "test-model"
|
||||
mock_agent.process_direct.assert_called_once_with(
|
||||
content="hello",
|
||||
session_key=API_SESSION_KEY,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
||||
call_log: list[str] = []
|
||||
|
||||
async def fake_process(content, session_key="", channel="", chat_id=""):
|
||||
call_log.append(session_key)
|
||||
return f"reply to {content}"
|
||||
|
||||
agent = MagicMock()
|
||||
agent.process_direct = fake_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
r1 = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "first"}]},
|
||||
)
|
||||
r2 = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "second"}]},
|
||||
)
|
||||
|
||||
assert r1.status == 200
|
||||
assert r2.status == 200
|
||||
assert call_log == [API_SESSION_KEY, API_SESSION_KEY]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
||||
order: list[str] = []
|
||||
|
||||
async def slow_process(content, session_key="", channel="", chat_id=""):
|
||||
order.append(f"start:{content}")
|
||||
await asyncio.sleep(0.1)
|
||||
order.append(f"end:{content}")
|
||||
return content
|
||||
|
||||
agent = MagicMock()
|
||||
agent.process_direct = slow_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
async def send(msg: str):
|
||||
return await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": msg}]},
|
||||
)
|
||||
|
||||
r1, r2 = await asyncio.gather(send("first"), send("second"))
|
||||
assert r1.status == 200
|
||||
assert r2.status == 200
|
||||
# Verify serialization: one process must fully finish before the other starts
|
||||
if order[0] == "start:first":
|
||||
assert order.index("end:first") < order.index("start:second")
|
||||
else:
|
||||
assert order.index("end:second") < order.index("start:first")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_models_endpoint(aiohttp_client, app) -> None:
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/v1/models")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["object"] == "list"
|
||||
assert body["data"][0]["id"] == "test-model"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(aiohttp_client, app) -> None:
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/health")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> None:
|
||||
app = create_app(mock_agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status == 200
|
||||
mock_agent.process_direct.assert_called_once_with(
|
||||
content="describe this",
|
||||
session_key=API_SESSION_KEY,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
||||
call_count = 0
|
||||
|
||||
async def sometimes_empty(content, session_key="", channel="", chat_id=""):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return ""
|
||||
return "recovered response"
|
||||
|
||||
agent = MagicMock()
|
||||
agent.process_direct = sometimes_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["choices"][0]["message"]["content"] == "recovered response"
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_falls_back(aiohttp_client) -> None:
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def always_empty(content, session_key="", channel="", chat_id=""):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return ""
|
||||
|
||||
agent = MagicMock()
|
||||
agent.process_direct = always_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
assert call_count == 2
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
|
||||
def test_source_checkout_import_uses_pyproject_version_without_metadata() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
expected = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))["project"][
|
||||
"version"
|
||||
]
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, {str(repo_root)!r})
|
||||
fake = types.ModuleType("nanobot.nanobot")
|
||||
fake.Nanobot = object
|
||||
fake.RunResult = object
|
||||
sys.modules["nanobot.nanobot"] = fake
|
||||
|
||||
import nanobot
|
||||
|
||||
print(nanobot.__version__)
|
||||
"""
|
||||
)
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-S", "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout.strip() == expected
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for exec tool environment isolation."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
_UNIX_ONLY = pytest.mark.skipif(sys.platform == "win32", reason="Unix shell commands")
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_does_not_leak_parent_env(monkeypatch):
|
||||
"""Env vars from the parent process must not be visible to commands."""
|
||||
monkeypatch.setenv("NANOBOT_SECRET_TOKEN", "super-secret-value")
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command="printenv NANOBOT_SECRET_TOKEN")
|
||||
assert "super-secret-value" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_has_working_path():
|
||||
"""Basic commands should be available via the login shell's PATH."""
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command="echo hello")
|
||||
assert "hello" in result
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_path_append():
|
||||
"""The pathAppend config should be available in the command's PATH."""
|
||||
tool = ExecTool(path_append="/opt/custom/bin")
|
||||
result = await tool.execute(command="echo $PATH")
|
||||
assert "/opt/custom/bin" in result
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_path_append_preserves_system_path():
|
||||
"""pathAppend must not clobber standard system paths."""
|
||||
tool = ExecTool(path_append="/opt/custom/bin")
|
||||
result = await tool.execute(command="ls /")
|
||||
assert "Exit code: 0" in result
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for cross-platform shell execution.
|
||||
|
||||
Verifies that ExecTool selects the correct shell, environment, path-append
|
||||
strategy, and sandbox behaviour per platform — without actually running
|
||||
platform-specific binaries (all subprocess calls are mocked).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
_WINDOWS_ENV_KEYS = {
|
||||
"APPDATA", "LOCALAPPDATA", "ProgramData",
|
||||
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildEnvUnix:
|
||||
|
||||
def test_expected_keys(self):
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
||||
env = ExecTool()._build_env()
|
||||
expected = {"HOME", "LANG", "TERM"}
|
||||
assert expected <= set(env)
|
||||
if sys.platform != "win32":
|
||||
assert set(env) == expected
|
||||
|
||||
def test_home_from_environ(self, monkeypatch):
|
||||
monkeypatch.setenv("HOME", "/Users/dev")
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
||||
env = ExecTool()._build_env()
|
||||
assert env["HOME"] == "/Users/dev"
|
||||
|
||||
def test_secrets_excluded(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
|
||||
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
||||
env = ExecTool()._build_env()
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "NANOBOT_TOKEN" not in env
|
||||
for v in env.values():
|
||||
assert "secret" not in v.lower()
|
||||
|
||||
|
||||
class TestBuildEnvWindows:
|
||||
|
||||
_EXPECTED_KEYS = {
|
||||
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
|
||||
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
|
||||
*_WINDOWS_ENV_KEYS,
|
||||
}
|
||||
|
||||
def test_expected_keys(self):
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
||||
env = ExecTool()._build_env()
|
||||
assert set(env) == self._EXPECTED_KEYS
|
||||
|
||||
def test_secrets_excluded(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
|
||||
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
||||
env = ExecTool()._build_env()
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "NANOBOT_TOKEN" not in env
|
||||
for v in env.values():
|
||||
assert "secret" not in v.lower()
|
||||
|
||||
def test_path_has_sensible_default(self):
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.dict("os.environ", {}, clear=True),
|
||||
):
|
||||
env = ExecTool()._build_env()
|
||||
assert "system32" in env["PATH"].lower()
|
||||
|
||||
def test_systemroot_forwarded(self, monkeypatch):
|
||||
monkeypatch.setenv("SYSTEMROOT", r"D:\Windows")
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
||||
env = ExecTool()._build_env()
|
||||
assert env["SYSTEMROOT"] == r"D:\Windows"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpawnUnix:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_bash(self):
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
):
|
||||
mock_exec.return_value = AsyncMock()
|
||||
await ExecTool._spawn("echo hi", "/tmp", {"HOME": "/tmp"})
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert "bash" in args[0]
|
||||
assert "-l" in args
|
||||
assert "-c" in args
|
||||
assert "echo hi" in args
|
||||
|
||||
|
||||
class TestSpawnWindows:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_comspec_from_env(self):
|
||||
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""}
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
):
|
||||
mock_exec.return_value = AsyncMock()
|
||||
await ExecTool._spawn("dir", r"C:\Users", env)
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert "cmd.exe" in args[0]
|
||||
assert "/c" in args
|
||||
assert "dir" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_default_comspec(self):
|
||||
env = {"PATH": ""}
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.dict("os.environ", {}, clear=True),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
):
|
||||
mock_exec.return_value = AsyncMock()
|
||||
await ExecTool._spawn("dir", r"C:\Users", env)
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert args[0] == "cmd.exe"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# path_append
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPathAppendPlatform:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_injects_export(self):
|
||||
"""On Unix, path_append is an export statement prepended to command."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_append="/opt/bin")
|
||||
await tool.execute(command="ls")
|
||||
|
||||
spawned_cmd = mock_spawn.call_args[0][0]
|
||||
assert 'export PATH="$PATH:/opt/bin"' in spawned_cmd
|
||||
assert spawned_cmd.endswith("ls")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_modifies_env(self):
|
||||
"""On Windows, path_append is appended to PATH in the env dict."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
captured_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_append=r"C:\tools\bin")
|
||||
await tool.execute(command="dir")
|
||||
|
||||
assert captured_env["PATH"].endswith(r";C:\tools\bin")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSandboxPlatform:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_skipped_on_windows(self):
|
||||
"""bwrap must be silently skipped on Windows, not crash."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(sandbox="bwrap")
|
||||
result = await tool.execute(command="dir")
|
||||
|
||||
assert "ok" in result
|
||||
spawned_cmd = mock_spawn.call_args[0][0]
|
||||
assert "bwrap" not in spawned_cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_applied_on_unix(self):
|
||||
"""On Unix, sandbox wrapping should still happen normally."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"sandboxed", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch("nanobot.agent.tools.shell.wrap_command", return_value="bwrap -- sh -c ls") as mock_wrap,
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(sandbox="bwrap", working_dir="/workspace")
|
||||
await tool.execute(command="ls")
|
||||
|
||||
mock_wrap.assert_called_once()
|
||||
spawned_cmd = mock_spawn.call_args[0][0]
|
||||
assert "bwrap" in spawned_cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end-to-end (mocked subprocess, full execute path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExecuteEndToEnd:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_full_path(self):
|
||||
"""Full execute() flow on Windows: env, spawn, output formatting."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"hello world\r\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command="echo hello world")
|
||||
|
||||
assert "hello world" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_full_path(self):
|
||||
"""Full execute() flow on Unix: env, spawn, output formatting."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"hello world\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command="echo hello world")
|
||||
|
||||
assert "hello world" in result
|
||||
assert "Exit code: 0" in result
|
||||
@@ -321,6 +321,22 @@ class TestWorkspaceRestriction:
|
||||
assert "Test Skill" in result
|
||||
assert "Error" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_allowed_in_media_dir(self, tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
media_dir = tmp_path / "media"
|
||||
media_dir.mkdir()
|
||||
media_file = media_dir / "photo.txt"
|
||||
media_file.write_text("shared media", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.filesystem.get_media_dir", lambda: media_dir)
|
||||
|
||||
tool = ReadFileTool(workspace=workspace, allowed_dir=workspace)
|
||||
result = await tool.execute(path=str(media_file))
|
||||
assert "shared media" in result
|
||||
assert "Error" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extra_dirs_does_not_widen_write(self, tmp_path):
|
||||
from nanobot.agent.tools.filesystem import WriteFileTool
|
||||
|
||||
@@ -7,7 +7,12 @@ from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.mcp import MCPToolWrapper, connect_mcp_servers
|
||||
from nanobot.agent.tools.mcp import (
|
||||
MCPResourceWrapper,
|
||||
MCPPromptWrapper,
|
||||
MCPToolWrapper,
|
||||
connect_mcp_servers,
|
||||
)
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
@@ -17,6 +22,16 @@ class _FakeTextContent:
|
||||
self.text = text
|
||||
|
||||
|
||||
class _FakeTextResourceContents:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
|
||||
class _FakeBlobResourceContents:
|
||||
def __init__(self, blob: bytes) -> None:
|
||||
self.blob = blob
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_mcp_runtime() -> dict[str, object | None]:
|
||||
return {"session": None}
|
||||
@@ -27,7 +42,11 @@ def _fake_mcp_module(
|
||||
monkeypatch: pytest.MonkeyPatch, fake_mcp_runtime: dict[str, object | None]
|
||||
) -> None:
|
||||
mod = ModuleType("mcp")
|
||||
mod.types = SimpleNamespace(TextContent=_FakeTextContent)
|
||||
mod.types = SimpleNamespace(
|
||||
TextContent=_FakeTextContent,
|
||||
TextResourceContents=_FakeTextResourceContents,
|
||||
BlobResourceContents=_FakeBlobResourceContents,
|
||||
)
|
||||
|
||||
class _FakeStdioServerParameters:
|
||||
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
||||
@@ -74,6 +93,18 @@ def _fake_mcp_module(
|
||||
monkeypatch.setitem(sys.modules, "mcp.client.sse", sse_mod)
|
||||
monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable_http_mod)
|
||||
|
||||
shared_mod = ModuleType("mcp.shared")
|
||||
exc_mod = ModuleType("mcp.shared.exceptions")
|
||||
|
||||
class _FakeMcpError(Exception):
|
||||
def __init__(self, code: int = -1, message: str = "error"):
|
||||
self.error = SimpleNamespace(code=code, message=message)
|
||||
super().__init__(message)
|
||||
|
||||
exc_mod.McpError = _FakeMcpError
|
||||
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
||||
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
||||
|
||||
|
||||
def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
||||
tool_def = SimpleNamespace(
|
||||
@@ -196,7 +227,7 @@ async def test_execute_re_raises_external_cancellation() -> None:
|
||||
|
||||
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10)
|
||||
task = asyncio.create_task(wrapper.execute())
|
||||
await started.wait()
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
|
||||
task.cancel()
|
||||
|
||||
@@ -343,3 +374,259 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
|
||||
assert "enabledTools entries not found: unknown" in warnings[-1]
|
||||
assert "Available raw names: demo" in warnings[-1]
|
||||
assert "Available wrapped names: mcp_test_demo" in warnings[-1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPResourceWrapper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_resource_def(
|
||||
name: str = "myres",
|
||||
uri: str = "file:///tmp/data.txt",
|
||||
description: str = "A test resource",
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(name=name, uri=uri, description=description)
|
||||
|
||||
|
||||
def _make_resource_wrapper(
|
||||
session: object, *, timeout: float = 0.1
|
||||
) -> MCPResourceWrapper:
|
||||
return MCPResourceWrapper(session, "srv", _make_resource_def(), resource_timeout=timeout)
|
||||
|
||||
|
||||
def test_resource_wrapper_properties() -> None:
|
||||
wrapper = MCPResourceWrapper(None, "myserver", _make_resource_def())
|
||||
assert wrapper.name == "mcp_myserver_resource_myres"
|
||||
assert "[MCP Resource]" in wrapper.description
|
||||
assert "A test resource" in wrapper.description
|
||||
assert "file:///tmp/data.txt" in wrapper.description
|
||||
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
|
||||
assert wrapper.read_only is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_wrapper_execute_returns_text() -> None:
|
||||
async def read_resource(uri: str) -> object:
|
||||
assert uri == "file:///tmp/data.txt"
|
||||
return SimpleNamespace(
|
||||
contents=[_FakeTextResourceContents("line1"), _FakeTextResourceContents("line2")]
|
||||
)
|
||||
|
||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
||||
result = await wrapper.execute()
|
||||
assert result == "line1\nline2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_wrapper_execute_handles_blob() -> None:
|
||||
async def read_resource(uri: str) -> object:
|
||||
return SimpleNamespace(contents=[_FakeBlobResourceContents(b"\x00\x01\x02")])
|
||||
|
||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
||||
result = await wrapper.execute()
|
||||
assert "[Binary resource: 3 bytes]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_wrapper_execute_handles_timeout() -> None:
|
||||
async def read_resource(uri: str) -> object:
|
||||
await asyncio.sleep(1)
|
||||
return SimpleNamespace(contents=[])
|
||||
|
||||
wrapper = _make_resource_wrapper(
|
||||
SimpleNamespace(read_resource=read_resource), timeout=0.01
|
||||
)
|
||||
result = await wrapper.execute()
|
||||
assert result == "(MCP resource read timed out after 0.01s)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_wrapper_execute_handles_error() -> None:
|
||||
async def read_resource(uri: str) -> object:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
||||
result = await wrapper.execute()
|
||||
assert result == "(MCP resource read failed: RuntimeError)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPPromptWrapper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_prompt_def(
|
||||
name: str = "myprompt",
|
||||
description: str = "A test prompt",
|
||||
arguments: list | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(name=name, description=description, arguments=arguments)
|
||||
|
||||
|
||||
def _make_prompt_wrapper(
|
||||
session: object, *, timeout: float = 0.1
|
||||
) -> MCPPromptWrapper:
|
||||
return MCPPromptWrapper(
|
||||
session, "srv", _make_prompt_def(), prompt_timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_wrapper_properties() -> None:
|
||||
arg1 = SimpleNamespace(name="topic", required=True)
|
||||
arg2 = SimpleNamespace(name="style", required=False)
|
||||
wrapper = MCPPromptWrapper(
|
||||
None, "myserver", _make_prompt_def(arguments=[arg1, arg2])
|
||||
)
|
||||
assert wrapper.name == "mcp_myserver_prompt_myprompt"
|
||||
assert "[MCP Prompt]" in wrapper.description
|
||||
assert "A test prompt" in wrapper.description
|
||||
assert "workflow guide" in wrapper.description
|
||||
assert wrapper.parameters["properties"]["topic"] == {"type": "string"}
|
||||
assert wrapper.parameters["properties"]["style"] == {"type": "string"}
|
||||
assert wrapper.parameters["required"] == ["topic"]
|
||||
assert wrapper.read_only is True
|
||||
|
||||
|
||||
def test_prompt_wrapper_no_arguments() -> None:
|
||||
wrapper = MCPPromptWrapper(None, "myserver", _make_prompt_def())
|
||||
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
|
||||
def test_prompt_wrapper_preserves_argument_descriptions() -> None:
|
||||
arg = SimpleNamespace(name="topic", required=True, description="The subject to discuss")
|
||||
wrapper = MCPPromptWrapper(None, "srv", _make_prompt_def(arguments=[arg]))
|
||||
assert wrapper.parameters["properties"]["topic"] == {
|
||||
"type": "string",
|
||||
"description": "The subject to discuss",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_wrapper_execute_returns_text() -> None:
|
||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||
assert name == "myprompt"
|
||||
msg1 = SimpleNamespace(
|
||||
role="user",
|
||||
content=[_FakeTextContent("You are an expert on {{topic}}.")],
|
||||
)
|
||||
msg2 = SimpleNamespace(
|
||||
role="assistant",
|
||||
content=[_FakeTextContent("Understood. Ask me anything.")],
|
||||
)
|
||||
return SimpleNamespace(messages=[msg1, msg2])
|
||||
|
||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
||||
result = await wrapper.execute(topic="AI")
|
||||
assert "You are an expert on {{topic}}." in result
|
||||
assert "Understood. Ask me anything." in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_wrapper_execute_handles_timeout() -> None:
|
||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||
await asyncio.sleep(1)
|
||||
return SimpleNamespace(messages=[])
|
||||
|
||||
wrapper = _make_prompt_wrapper(
|
||||
SimpleNamespace(get_prompt=get_prompt), timeout=0.01
|
||||
)
|
||||
result = await wrapper.execute()
|
||||
assert result == "(MCP prompt call timed out after 0.01s)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||
raise McpError(code=42, message="invalid argument")
|
||||
|
||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
||||
result = await wrapper.execute()
|
||||
assert "invalid argument" in result
|
||||
assert "code 42" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_wrapper_execute_handles_error() -> None:
|
||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
||||
result = await wrapper.execute()
|
||||
assert result == "(MCP prompt call failed: RuntimeError)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect_mcp_servers: resources + prompts integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fake_session_with_capabilities(
|
||||
tool_names: list[str],
|
||||
resource_names: list[str] | None = None,
|
||||
prompt_names: list[str] | None = None,
|
||||
) -> SimpleNamespace:
|
||||
async def initialize() -> None:
|
||||
return None
|
||||
|
||||
async def list_tools() -> SimpleNamespace:
|
||||
return SimpleNamespace(tools=[_make_tool_def(name) for name in tool_names])
|
||||
|
||||
async def list_resources() -> SimpleNamespace:
|
||||
resources = []
|
||||
for rname in resource_names or []:
|
||||
resources.append(
|
||||
SimpleNamespace(
|
||||
name=rname,
|
||||
uri=f"file:///{rname}",
|
||||
description=f"{rname} resource",
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(resources=resources)
|
||||
|
||||
async def list_prompts() -> SimpleNamespace:
|
||||
prompts = []
|
||||
for pname in prompt_names or []:
|
||||
prompts.append(
|
||||
SimpleNamespace(
|
||||
name=pname,
|
||||
description=f"{pname} prompt",
|
||||
arguments=None,
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(prompts=prompts)
|
||||
|
||||
return SimpleNamespace(
|
||||
initialize=initialize,
|
||||
list_tools=list_tools,
|
||||
list_resources=list_resources,
|
||||
list_prompts=list_prompts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_registers_resources_and_prompts(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
|
||||
tool_names=["tool_a"],
|
||||
resource_names=["res_b"],
|
||||
prompt_names=["prompt_c"],
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
try:
|
||||
await connect_mcp_servers(
|
||||
{"test": MCPServerConfig(command="fake")},
|
||||
registry,
|
||||
stack,
|
||||
)
|
||||
finally:
|
||||
await stack.aclose()
|
||||
|
||||
assert "mcp_test_tool_a" in registry.tool_names
|
||||
assert "mcp_test_resource_res_b" in registry.tool_names
|
||||
assert "mcp_test_prompt_prompt_c" in registry.tool_names
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestMessageToolSuppressLogic:
|
||||
assert final_content == "Done"
|
||||
assert progress == [
|
||||
("Visible", False),
|
||||
('read_file("foo.txt")', True),
|
||||
('read foo.txt', True),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for nanobot.agent.tools.sandbox."""
|
||||
|
||||
import shlex
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.sandbox import wrap_command
|
||||
|
||||
|
||||
def _parse(cmd: str) -> list[str]:
|
||||
"""Split a wrapped command back into tokens for assertion."""
|
||||
return shlex.split(cmd)
|
||||
|
||||
|
||||
class TestBwrapBackend:
|
||||
def test_basic_structure(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
result = wrap_command("bwrap", "echo hi", ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
assert tokens[0] == "bwrap"
|
||||
assert "--new-session" in tokens
|
||||
assert "--die-with-parent" in tokens
|
||||
assert "--ro-bind" in tokens
|
||||
assert "--proc" in tokens
|
||||
assert "--dev" in tokens
|
||||
assert "--tmpfs" in tokens
|
||||
|
||||
sep = tokens.index("--")
|
||||
assert tokens[sep + 1:] == ["sh", "-c", "echo hi"]
|
||||
|
||||
def test_workspace_bind_mounted_rw(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
result = wrap_command("bwrap", "ls", ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
bind_idx = [i for i, t in enumerate(tokens) if t == "--bind"]
|
||||
assert any(tokens[i + 1] == ws and tokens[i + 2] == ws for i in bind_idx)
|
||||
|
||||
def test_parent_dir_masked_with_tmpfs(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
result = wrap_command("bwrap", "ls", str(ws), str(ws))
|
||||
tokens = _parse(result)
|
||||
|
||||
tmpfs_indices = [i for i, t in enumerate(tokens) if t == "--tmpfs"]
|
||||
tmpfs_targets = {tokens[i + 1] for i in tmpfs_indices}
|
||||
assert str(ws.parent) in tmpfs_targets
|
||||
|
||||
def test_cwd_inside_workspace(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
sub = ws / "src" / "lib"
|
||||
result = wrap_command("bwrap", "pwd", str(ws), str(sub))
|
||||
tokens = _parse(result)
|
||||
|
||||
chdir_idx = tokens.index("--chdir")
|
||||
assert tokens[chdir_idx + 1] == str(sub)
|
||||
|
||||
def test_cwd_outside_workspace_falls_back(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
outside = tmp_path / "other"
|
||||
result = wrap_command("bwrap", "pwd", str(ws), str(outside))
|
||||
tokens = _parse(result)
|
||||
|
||||
chdir_idx = tokens.index("--chdir")
|
||||
assert tokens[chdir_idx + 1] == str(ws.resolve())
|
||||
|
||||
def test_command_with_special_characters(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
cmd = "echo 'hello world' && cat \"file with spaces.txt\""
|
||||
result = wrap_command("bwrap", cmd, ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
sep = tokens.index("--")
|
||||
assert tokens[sep + 1:] == ["sh", "-c", cmd]
|
||||
|
||||
def test_system_dirs_ro_bound(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
result = wrap_command("bwrap", "ls", ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
ro_bind_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind"]
|
||||
ro_targets = {tokens[i + 1] for i in ro_bind_indices}
|
||||
assert "/usr" in ro_targets
|
||||
|
||||
def test_optional_dirs_use_ro_bind_try(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
result = wrap_command("bwrap", "ls", ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"]
|
||||
try_targets = {tokens[i + 1] for i in try_indices}
|
||||
assert "/bin" in try_targets
|
||||
assert "/etc/ssl/certs" in try_targets
|
||||
|
||||
def test_media_dir_ro_bind(self, tmp_path, monkeypatch):
|
||||
"""Media directory should be read-only mounted inside the sandbox."""
|
||||
fake_media = tmp_path / "media"
|
||||
fake_media.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.sandbox.get_media_dir",
|
||||
lambda: fake_media,
|
||||
)
|
||||
ws = str(tmp_path / "project")
|
||||
result = wrap_command("bwrap", "ls", ws, ws)
|
||||
tokens = _parse(result)
|
||||
|
||||
try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"]
|
||||
try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in try_indices}
|
||||
assert (str(fake_media), str(fake_media)) in try_pairs
|
||||
|
||||
|
||||
class TestUnknownBackend:
|
||||
def test_raises_value_error(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
with pytest.raises(ValueError, match="Unknown sandbox backend"):
|
||||
wrap_command("nonexistent", "ls", ws, ws)
|
||||
|
||||
def test_empty_string_raises(self, tmp_path):
|
||||
ws = str(tmp_path / "project")
|
||||
with pytest.raises(ValueError):
|
||||
wrap_command("", "ls", ws, ws)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Tests for grep/glob search tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.search import GlobTool, GrepTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_matches_recursively_and_skips_noise_dirs(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "nested").mkdir()
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "src" / "app.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
(tmp_path / "nested" / "util.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
(tmp_path / "node_modules" / "skip.py").write_text("print('skip')\n", encoding="utf-8")
|
||||
|
||||
tool = GlobTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(pattern="*.py", path=".")
|
||||
|
||||
assert "src/app.py" in result
|
||||
assert "nested/util.py" in result
|
||||
assert "node_modules/skip.py" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_can_return_directories_only(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "api").mkdir(parents=True)
|
||||
(tmp_path / "src" / "api" / "handlers.py").write_text("ok\n", encoding="utf-8")
|
||||
|
||||
tool = GlobTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="api",
|
||||
path="src",
|
||||
entry_type="dirs",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/api/"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text(
|
||||
"alpha\nbeta\nmatch_here\ngamma\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "README.md").write_text("match_here\n", encoding="utf-8")
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="match_here",
|
||||
path=".",
|
||||
glob="*.py",
|
||||
output_mode="content",
|
||||
context_before=1,
|
||||
context_after=1,
|
||||
)
|
||||
|
||||
assert "src/main.py:3" in result
|
||||
assert " 2| beta" in result
|
||||
assert "> 3| match_here" in result
|
||||
assert " 4| gamma" in result
|
||||
assert "README.md" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_defaults_to_files_with_matches(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text("match_here\n", encoding="utf-8")
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="match_here",
|
||||
path="src",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/main.py"]
|
||||
assert "1|" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_supports_case_insensitive_search(tmp_path: Path) -> None:
|
||||
(tmp_path / "memory").mkdir()
|
||||
(tmp_path / "memory" / "HISTORY.md").write_text(
|
||||
"[2026-04-02 10:00] OAuth token rotated\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="oauth",
|
||||
path="memory/HISTORY.md",
|
||||
case_insensitive=True,
|
||||
output_mode="content",
|
||||
)
|
||||
|
||||
assert "memory/HISTORY.md:1" in result
|
||||
assert "OAuth token rotated" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_type_filter_limits_files(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "a.py").write_text("needle\n", encoding="utf-8")
|
||||
(tmp_path / "src" / "b.md").write_text("needle\n", encoding="utf-8")
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="needle",
|
||||
path="src",
|
||||
type="py",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/a.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_fixed_strings_treats_regex_chars_literally(tmp_path: Path) -> None:
|
||||
(tmp_path / "memory").mkdir()
|
||||
(tmp_path / "memory" / "HISTORY.md").write_text(
|
||||
"[2026-04-02 10:00] OAuth token rotated\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="[2026-04-02 10:00]",
|
||||
path="memory/HISTORY.md",
|
||||
fixed_strings=True,
|
||||
output_mode="content",
|
||||
)
|
||||
|
||||
assert "memory/HISTORY.md:1" in result
|
||||
assert "[2026-04-02 10:00] OAuth token rotated" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_files_with_matches_mode_returns_unique_paths(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
a = tmp_path / "src" / "a.py"
|
||||
b = tmp_path / "src" / "b.py"
|
||||
a.write_text("needle\nneedle\n", encoding="utf-8")
|
||||
b.write_text("needle\n", encoding="utf-8")
|
||||
os.utime(a, (1, 1))
|
||||
os.utime(b, (2, 2))
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="needle",
|
||||
path="src",
|
||||
output_mode="files_with_matches",
|
||||
)
|
||||
|
||||
assert result.splitlines() == ["src/b.py", "src/a.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
for name in ("a.py", "b.py", "c.py"):
|
||||
(tmp_path / "src" / name).write_text("needle\n", encoding="utf-8")
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="needle",
|
||||
path="src",
|
||||
head_limit=1,
|
||||
offset=1,
|
||||
)
|
||||
|
||||
lines = result.splitlines()
|
||||
assert lines[0] == "src/b.py"
|
||||
assert "pagination: limit=1, offset=1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_count_mode_reports_counts_per_file(tmp_path: Path) -> None:
|
||||
(tmp_path / "logs").mkdir()
|
||||
(tmp_path / "logs" / "one.log").write_text("warn\nok\nwarn\n", encoding="utf-8")
|
||||
(tmp_path / "logs" / "two.log").write_text("warn\n", encoding="utf-8")
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="warn",
|
||||
path="logs",
|
||||
output_mode="count",
|
||||
)
|
||||
|
||||
assert "logs/one.log: 2" in result
|
||||
assert "logs/two.log: 1" in result
|
||||
assert "total matches: 3 in 2 files" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_files_with_matches_mode_respects_max_results(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
files = []
|
||||
for idx, name in enumerate(("a.py", "b.py", "c.py"), start=1):
|
||||
file_path = tmp_path / "src" / name
|
||||
file_path.write_text("needle\n", encoding="utf-8")
|
||||
os.utime(file_path, (idx, idx))
|
||||
files.append(file_path)
|
||||
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="needle",
|
||||
path="src",
|
||||
output_mode="files_with_matches",
|
||||
max_results=2,
|
||||
)
|
||||
|
||||
assert result.splitlines()[:2] == ["src/c.py", "src/b.py"]
|
||||
assert "pagination: limit=2, offset=0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_supports_head_limit_offset_and_recent_first(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
a = tmp_path / "src" / "a.py"
|
||||
b = tmp_path / "src" / "b.py"
|
||||
c = tmp_path / "src" / "c.py"
|
||||
a.write_text("a\n", encoding="utf-8")
|
||||
b.write_text("b\n", encoding="utf-8")
|
||||
c.write_text("c\n", encoding="utf-8")
|
||||
|
||||
os.utime(a, (1, 1))
|
||||
os.utime(b, (2, 2))
|
||||
os.utime(c, (3, 3))
|
||||
|
||||
tool = GlobTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(
|
||||
pattern="*.py",
|
||||
path="src",
|
||||
head_limit=1,
|
||||
offset=1,
|
||||
)
|
||||
|
||||
lines = result.splitlines()
|
||||
assert lines[0] == "src/b.py"
|
||||
assert "pagination: limit=1, offset=1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_reports_skipped_binary_and_large_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
(tmp_path / "binary.bin").write_bytes(b"\x00\x01\x02")
|
||||
(tmp_path / "large.txt").write_text("x" * 20, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(GrepTool, "_MAX_FILE_BYTES", 10)
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
result = await tool.execute(pattern="needle", path=".")
|
||||
|
||||
assert "No matches found" in result
|
||||
assert "skipped 1 binary/unreadable files" in result
|
||||
assert "skipped 1 large files" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_tools_reject_paths_outside_workspace(tmp_path: Path) -> None:
|
||||
outside = tmp_path.parent / "outside-search.txt"
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
|
||||
grep_tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
glob_tool = GlobTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
|
||||
grep_result = await grep_tool.execute(pattern="secret", path=str(outside))
|
||||
glob_result = await glob_tool.execute(pattern="*.txt", path=str(outside.parent))
|
||||
|
||||
assert grep_result.startswith("Error:")
|
||||
assert glob_result.startswith("Error:")
|
||||
|
||||
|
||||
def test_agent_loop_registers_grep_and_glob(tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
assert "grep" in loop.tools.tool_names
|
||||
assert "glob" in loop.tools.tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=4096,
|
||||
)
|
||||
captured: dict[str, list[str]] = {}
|
||||
|
||||
async def fake_run(spec):
|
||||
captured["tool_names"] = spec.tools.tool_names
|
||||
return SimpleNamespace(
|
||||
stop_reason="ok",
|
||||
final_content="done",
|
||||
tool_events=[],
|
||||
error=None,
|
||||
)
|
||||
|
||||
mgr.runner.run = fake_run
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"})
|
||||
|
||||
assert "grep" in captured["tool_names"]
|
||||
assert "glob" in captured["tool_names"]
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
class _FakeTool(Tool):
|
||||
def __init__(self, name: str):
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return f"{self._name} tool"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
return kwargs
|
||||
|
||||
|
||||
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for definition in definitions:
|
||||
fn = definition.get("function", {})
|
||||
names.append(fn.get("name", ""))
|
||||
return names
|
||||
|
||||
|
||||
def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(_FakeTool("mcp_git_status"))
|
||||
registry.register(_FakeTool("write_file"))
|
||||
registry.register(_FakeTool("mcp_fs_list"))
|
||||
registry.register(_FakeTool("read_file"))
|
||||
|
||||
assert _tool_names(registry.get_definitions()) == [
|
||||
"read_file",
|
||||
"write_file",
|
||||
"mcp_fs_list",
|
||||
"mcp_git_status",
|
||||
]
|
||||
@@ -1,5 +1,17 @@
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools import (
|
||||
ArraySchema,
|
||||
IntegerSchema,
|
||||
ObjectSchema,
|
||||
Schema,
|
||||
StringSchema,
|
||||
tool_parameters,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
@@ -41,6 +53,103 @@ class SampleTool(Tool):
|
||||
return "ok"
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
query=StringSchema(min_length=2),
|
||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||
required=["query", "count"],
|
||||
)
|
||||
)
|
||||
class DecoratedSampleTool(Tool):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "decorated_sample"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "decorated sample tool"
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
return f"ok:{kwargs['count']}"
|
||||
|
||||
|
||||
def test_schema_validate_value_matches_tool_validate_params() -> None:
|
||||
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
|
||||
root = tool_parameters_schema(
|
||||
query=StringSchema(min_length=2),
|
||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||
required=["query", "count"],
|
||||
)
|
||||
obj = ObjectSchema(
|
||||
query=StringSchema(min_length=2),
|
||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||
required=["query", "count"],
|
||||
)
|
||||
params = {"query": "h", "count": 2}
|
||||
|
||||
class _Mini(Tool):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "m"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return root
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
return ""
|
||||
|
||||
expected = _Mini().validate_params(params)
|
||||
assert Schema.validate_json_schema_value(params, root, "") == expected
|
||||
assert obj.validate_value(params, "") == expected
|
||||
assert IntegerSchema(0, minimum=1).validate_value(0, "n") == ["n must be >= 1"]
|
||||
|
||||
|
||||
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
|
||||
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
|
||||
built = tool_parameters_schema(
|
||||
query=StringSchema(min_length=2),
|
||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||
mode=StringSchema("", enum=["fast", "full"]),
|
||||
meta=ObjectSchema(
|
||||
tag=StringSchema(""),
|
||||
flags=ArraySchema(StringSchema("")),
|
||||
required=["tag"],
|
||||
),
|
||||
required=["query", "count"],
|
||||
)
|
||||
assert built == SampleTool().parameters
|
||||
|
||||
|
||||
def test_tool_parameters_returns_fresh_copy_per_access() -> None:
|
||||
tool = DecoratedSampleTool()
|
||||
|
||||
first = tool.parameters
|
||||
second = tool.parameters
|
||||
|
||||
assert first == second
|
||||
assert first is not second
|
||||
assert first["properties"] is not second["properties"]
|
||||
|
||||
first["properties"]["query"]["minLength"] = 99
|
||||
assert tool.parameters["properties"]["query"]["minLength"] == 2
|
||||
|
||||
|
||||
async def test_registry_executes_decorated_tool_end_to_end() -> None:
|
||||
reg = ToolRegistry()
|
||||
reg.register(DecoratedSampleTool())
|
||||
|
||||
ok = await reg.execute("decorated_sample", {"query": "hello", "count": "3"})
|
||||
assert ok == "ok:3"
|
||||
|
||||
err = await reg.execute("decorated_sample", {"query": "h", "count": 3})
|
||||
assert "Invalid parameters" in err
|
||||
|
||||
|
||||
def test_validate_params_missing_required() -> None:
|
||||
tool = SampleTool()
|
||||
errors = tool.validate_params({"query": "hi"})
|
||||
@@ -95,6 +204,14 @@ def test_exec_extract_absolute_paths_keeps_full_windows_path() -> None:
|
||||
assert paths == [r"C:\user\workspace\txt"]
|
||||
|
||||
|
||||
def test_exec_extract_absolute_paths_captures_windows_drive_root_path() -> None:
|
||||
"""Windows drive root paths like `E:\\` must be extracted for workspace guarding."""
|
||||
# Note: raw strings cannot end with a single backslash.
|
||||
cmd = "dir E:\\"
|
||||
paths = ExecTool._extract_absolute_paths(cmd)
|
||||
assert paths == ["E:\\"]
|
||||
|
||||
|
||||
def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None:
|
||||
cmd = ".venv/bin/python script.py"
|
||||
paths = ExecTool._extract_absolute_paths(cmd)
|
||||
@@ -134,6 +251,58 @@ def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
|
||||
assert error == "Error: Command blocked by safety guard (path outside working dir)"
|
||||
|
||||
|
||||
def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None:
|
||||
media_dir = tmp_path / "media"
|
||||
media_dir.mkdir()
|
||||
media_file = media_dir / "photo.jpg"
|
||||
media_file.write_text("ok", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.shell.get_media_dir", lambda: media_dir)
|
||||
|
||||
tool = ExecTool(restrict_to_workspace=True)
|
||||
error = tool._guard_command(f'cat "{media_file}"', str(tmp_path / "workspace"))
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> None:
|
||||
import nanobot.agent.tools.shell as shell_mod
|
||||
|
||||
class FakeWindowsPath:
|
||||
def __init__(self, raw: str) -> None:
|
||||
self.raw = raw.rstrip("\\") + ("\\" if raw.endswith("\\") else "")
|
||||
|
||||
def resolve(self) -> "FakeWindowsPath":
|
||||
return self
|
||||
|
||||
def expanduser(self) -> "FakeWindowsPath":
|
||||
return self
|
||||
|
||||
def is_absolute(self) -> bool:
|
||||
return len(self.raw) >= 3 and self.raw[1:3] == ":\\"
|
||||
|
||||
@property
|
||||
def parents(self) -> list["FakeWindowsPath"]:
|
||||
if not self.is_absolute():
|
||||
return []
|
||||
trimmed = self.raw.rstrip("\\")
|
||||
if len(trimmed) <= 2:
|
||||
return []
|
||||
idx = trimmed.rfind("\\")
|
||||
if idx <= 2:
|
||||
return [FakeWindowsPath(trimmed[:2] + "\\")]
|
||||
parent = FakeWindowsPath(trimmed[:idx])
|
||||
return [parent, *parent.parents]
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, FakeWindowsPath) and self.raw.lower() == other.raw.lower()
|
||||
|
||||
monkeypatch.setattr(shell_mod, "Path", FakeWindowsPath)
|
||||
|
||||
tool = ExecTool(restrict_to_workspace=True)
|
||||
error = tool._guard_command("dir E:\\", "E:\\workspace")
|
||||
assert error == "Error: Command blocked by safety guard (path outside working dir)"
|
||||
|
||||
|
||||
# --- cast_params tests ---
|
||||
|
||||
|
||||
@@ -380,10 +549,15 @@ async def test_exec_head_tail_truncation() -> None:
|
||||
"""Long output should preserve both head and tail."""
|
||||
tool = ExecTool()
|
||||
# Generate output that exceeds _MAX_OUTPUT (10_000 chars)
|
||||
# Use python to generate output to avoid command line length limits
|
||||
result = await tool.execute(
|
||||
command="python -c \"print('A' * 6000 + '\\n' + 'B' * 6000)\""
|
||||
)
|
||||
# Use current interpreter (PATH may not have `python`). ExecTool uses
|
||||
# create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe
|
||||
# rules, so list2cmdline is appropriate there.
|
||||
script = "print('A' * 6000 + '\\n' + 'B' * 6000)"
|
||||
if sys.platform == "win32":
|
||||
command = subprocess.list2cmdline([sys.executable, "-c", script])
|
||||
else:
|
||||
command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}"
|
||||
result = await tool.execute(command=command)
|
||||
assert "chars truncated" in result
|
||||
# Head portion should start with As
|
||||
assert result.startswith("A")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for multi-provider web search."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -160,3 +162,70 @@ async def test_searxng_invalid_url():
|
||||
tool = _tool(provider="searxng", base_url="not-a-url")
|
||||
result = await tool.execute(query="test")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jina_422_falls_back_to_duckduckgo(monkeypatch):
|
||||
class MockDDGS:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
def text(self, query, max_results=5):
|
||||
return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}]
|
||||
|
||||
async def mock_get(self, url, **kw):
|
||||
assert "s.jina.ai" in str(url)
|
||||
raise httpx.HTTPStatusError(
|
||||
"422 Unprocessable Entity",
|
||||
request=httpx.Request("GET", str(url)),
|
||||
response=httpx.Response(422, request=httpx.Request("GET", str(url))),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
|
||||
monkeypatch.setattr("ddgs.DDGS", MockDDGS)
|
||||
|
||||
tool = _tool(provider="jina", api_key="jina-key")
|
||||
result = await tool.execute(query="test")
|
||||
assert "DuckDuckGo fallback" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jina_search_uses_path_encoded_query(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
async def mock_get(self, url, **kw):
|
||||
calls["url"] = str(url)
|
||||
calls["params"] = kw.get("params")
|
||||
return _response(json={
|
||||
"data": [{"title": "Jina Result", "url": "https://jina.ai", "content": "AI search"}]
|
||||
})
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
|
||||
tool = _tool(provider="jina", api_key="jina-key")
|
||||
await tool.execute(query="hello world")
|
||||
assert calls["url"].rstrip("/") == "https://s.jina.ai/hello%20world"
|
||||
assert calls["params"] in (None, {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duckduckgo_timeout_returns_error(monkeypatch):
|
||||
"""asyncio.wait_for guard should fire when DDG search hangs."""
|
||||
import threading
|
||||
gate = threading.Event()
|
||||
|
||||
class HangingDDGS:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
def text(self, query, max_results=5):
|
||||
gate.wait(timeout=10)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("ddgs.DDGS", HangingDDGS)
|
||||
tool = _tool(provider="duckduckgo")
|
||||
tool.config.timeout = 0.2
|
||||
result = await tool.execute(query="test")
|
||||
gate.set()
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for abbreviate_path utility."""
|
||||
|
||||
import os
|
||||
from nanobot.utils.path import abbreviate_path
|
||||
|
||||
|
||||
class TestAbbreviatePathShort:
|
||||
def test_short_path_unchanged(self):
|
||||
assert abbreviate_path("/home/user/file.py") == "/home/user/file.py"
|
||||
|
||||
def test_exact_max_len_unchanged(self):
|
||||
path = "/a/b/c" # 7 chars
|
||||
assert abbreviate_path("/a/b/c", max_len=7) == "/a/b/c"
|
||||
|
||||
def test_basename_only(self):
|
||||
assert abbreviate_path("file.py") == "file.py"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert abbreviate_path("") == ""
|
||||
|
||||
|
||||
class TestAbbreviatePathHome:
|
||||
def test_home_replacement(self):
|
||||
home = os.path.expanduser("~")
|
||||
result = abbreviate_path(f"{home}/project/file.py")
|
||||
assert result.startswith("~/")
|
||||
assert result.endswith("file.py")
|
||||
|
||||
def test_home_preserves_short_path(self):
|
||||
home = os.path.expanduser("~")
|
||||
result = abbreviate_path(f"{home}/a.py")
|
||||
assert result == "~/a.py"
|
||||
|
||||
|
||||
class TestAbbreviatePathLong:
|
||||
def test_long_path_keeps_basename(self):
|
||||
path = "/a/b/c/d/e/f/g/h/very_long_filename.py"
|
||||
result = abbreviate_path(path, max_len=30)
|
||||
assert result.endswith("very_long_filename.py")
|
||||
assert "\u2026" in result
|
||||
|
||||
def test_long_path_keeps_parent_dir(self):
|
||||
path = "/a/b/c/d/e/f/g/h/src/loop.py"
|
||||
result = abbreviate_path(path, max_len=30)
|
||||
assert "loop.py" in result
|
||||
assert "src" in result
|
||||
|
||||
def test_very_long_path_just_basename(self):
|
||||
path = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.py"
|
||||
result = abbreviate_path(path, max_len=20)
|
||||
assert result.endswith("file.py")
|
||||
assert len(result) <= 20
|
||||
|
||||
|
||||
class TestAbbreviatePathWindows:
|
||||
def test_windows_drive_path(self):
|
||||
path = "D:\\Documents\\GitHub\\nanobot\\src\\utils\\helpers.py"
|
||||
result = abbreviate_path(path, max_len=40)
|
||||
assert result.endswith("helpers.py")
|
||||
assert "nanobot" in result
|
||||
|
||||
def test_windows_home(self):
|
||||
home = os.path.expanduser("~")
|
||||
path = os.path.join(home, ".nanobot", "workspace", "log.txt")
|
||||
result = abbreviate_path(path)
|
||||
assert result.startswith("~/")
|
||||
assert "log.txt" in result
|
||||
|
||||
|
||||
class TestAbbreviatePathURLs:
|
||||
def test_url_keeps_domain_and_filename(self):
|
||||
url = "https://example.com/api/v2/long/path/resource.json"
|
||||
result = abbreviate_path(url, max_len=40)
|
||||
assert "resource.json" in result
|
||||
assert "example.com" in result
|
||||
|
||||
def test_short_url_unchanged(self):
|
||||
url = "https://example.com/api"
|
||||
assert abbreviate_path(url) == url
|
||||
|
||||
def test_url_no_path_just_domain(self):
|
||||
"""G3: URL with no path should return as-is if short enough."""
|
||||
url = "https://example.com"
|
||||
assert abbreviate_path(url) == url
|
||||
|
||||
def test_url_with_query_string(self):
|
||||
"""G3: URL with query params should abbreviate path part."""
|
||||
url = "https://example.com/api/v2/endpoint?key=value&other=123"
|
||||
result = abbreviate_path(url, max_len=40)
|
||||
assert "example.com" in result
|
||||
assert "\u2026" in result
|
||||
|
||||
def test_url_very_long_basename(self):
|
||||
"""G3: URL with very long basename should truncate basename."""
|
||||
url = "https://example.com/path/very_long_resource_name_file.json"
|
||||
result = abbreviate_path(url, max_len=35)
|
||||
assert "example.com" in result
|
||||
assert "\u2026" in result
|
||||
|
||||
def test_url_negative_budget_consistent_format(self):
|
||||
"""I3: Negative budget should still produce domain/…/basename format."""
|
||||
url = "https://a.co/very/deep/path/with/lots/of/segments/and/a/long/basename.txt"
|
||||
result = abbreviate_path(url, max_len=20)
|
||||
assert "a.co" in result
|
||||
assert "/\u2026/" in result
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for restart notice helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from nanobot.utils.restart import (
|
||||
RestartNotice,
|
||||
consume_restart_notice_from_env,
|
||||
format_restart_completed_message,
|
||||
set_restart_notice_to_env,
|
||||
should_show_cli_restart_notice,
|
||||
)
|
||||
|
||||
|
||||
def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
|
||||
|
||||
set_restart_notice_to_env(channel="feishu", chat_id="oc_123")
|
||||
|
||||
notice = consume_restart_notice_from_env()
|
||||
assert notice is not None
|
||||
assert notice.channel == "feishu"
|
||||
assert notice.chat_id == "oc_123"
|
||||
assert notice.started_at_raw
|
||||
|
||||
# Consumed values should be cleared from env.
|
||||
assert consume_restart_notice_from_env() is None
|
||||
assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ
|
||||
assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ
|
||||
assert "NANOBOT_RESTART_STARTED_AT" not in os.environ
|
||||
|
||||
|
||||
def test_format_restart_completed_message_with_elapsed(monkeypatch):
|
||||
monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0)
|
||||
assert format_restart_completed_message("100.0") == "Restart completed in 2.0s."
|
||||
|
||||
|
||||
def test_should_show_cli_restart_notice():
|
||||
notice = RestartNotice(channel="cli", chat_id="direct", started_at_raw="100")
|
||||
assert should_show_cli_restart_notice(notice, "cli:direct") is True
|
||||
assert should_show_cli_restart_notice(notice, "cli:other") is False
|
||||
assert should_show_cli_restart_notice(notice, "direct") is True
|
||||
|
||||
non_cli = RestartNotice(channel="feishu", chat_id="oc_1", started_at_raw="100")
|
||||
assert should_show_cli_restart_notice(non_cli, "cli:direct") is False
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for web search provider usage fetching and /status integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.utils.searchusage import (
|
||||
SearchUsageInfo,
|
||||
_parse_tavily_usage,
|
||||
fetch_search_usage,
|
||||
)
|
||||
from nanobot.utils.helpers import build_status_content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SearchUsageInfo.format() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSearchUsageInfoFormat:
|
||||
def test_unsupported_provider_shows_no_tracking(self):
|
||||
info = SearchUsageInfo(provider="duckduckgo", supported=False)
|
||||
text = info.format()
|
||||
assert "duckduckgo" in text
|
||||
assert "not available" in text
|
||||
|
||||
def test_supported_with_error(self):
|
||||
info = SearchUsageInfo(provider="tavily", supported=True, error="HTTP 401")
|
||||
text = info.format()
|
||||
assert "tavily" in text
|
||||
assert "HTTP 401" in text
|
||||
assert "unavailable" in text
|
||||
|
||||
def test_full_tavily_usage(self):
|
||||
info = SearchUsageInfo(
|
||||
provider="tavily",
|
||||
supported=True,
|
||||
used=142,
|
||||
limit=1000,
|
||||
remaining=858,
|
||||
reset_date="2026-05-01",
|
||||
search_used=120,
|
||||
extract_used=15,
|
||||
crawl_used=7,
|
||||
)
|
||||
text = info.format()
|
||||
assert "tavily" in text
|
||||
assert "142 / 1000" in text
|
||||
assert "858" in text
|
||||
assert "2026-05-01" in text
|
||||
assert "Search: 120" in text
|
||||
assert "Extract: 15" in text
|
||||
assert "Crawl: 7" in text
|
||||
|
||||
def test_usage_without_limit(self):
|
||||
info = SearchUsageInfo(provider="tavily", supported=True, used=50)
|
||||
text = info.format()
|
||||
assert "50 requests" in text
|
||||
assert "/" not in text.split("Usage:")[1].split("\n")[0]
|
||||
|
||||
def test_no_breakdown_when_none(self):
|
||||
info = SearchUsageInfo(
|
||||
provider="tavily", supported=True, used=10, limit=100, remaining=90
|
||||
)
|
||||
text = info.format()
|
||||
assert "Breakdown" not in text
|
||||
|
||||
def test_brave_unsupported(self):
|
||||
info = SearchUsageInfo(provider="brave", supported=False)
|
||||
text = info.format()
|
||||
assert "brave" in text
|
||||
assert "not available" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_tavily_usage tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseTavilyUsage:
|
||||
def test_full_response(self):
|
||||
data = {
|
||||
"account": {
|
||||
"current_plan": "Researcher",
|
||||
"plan_usage": 142,
|
||||
"plan_limit": 1000,
|
||||
"search_usage": 120,
|
||||
"extract_usage": 15,
|
||||
"crawl_usage": 7,
|
||||
"map_usage": 0,
|
||||
"research_usage": 0,
|
||||
"paygo_usage": 0,
|
||||
"paygo_limit": None,
|
||||
},
|
||||
}
|
||||
info = _parse_tavily_usage(data)
|
||||
assert info.provider == "tavily"
|
||||
assert info.supported is True
|
||||
assert info.used == 142
|
||||
assert info.limit == 1000
|
||||
assert info.remaining == 858
|
||||
assert info.search_used == 120
|
||||
assert info.extract_used == 15
|
||||
assert info.crawl_used == 7
|
||||
|
||||
def test_remaining_computed(self):
|
||||
data = {"account": {"plan_usage": 300, "plan_limit": 1000}}
|
||||
info = _parse_tavily_usage(data)
|
||||
assert info.remaining == 700
|
||||
|
||||
def test_remaining_not_negative(self):
|
||||
data = {"account": {"plan_usage": 1100, "plan_limit": 1000}}
|
||||
info = _parse_tavily_usage(data)
|
||||
assert info.remaining == 0
|
||||
|
||||
def test_empty_response(self):
|
||||
info = _parse_tavily_usage({})
|
||||
assert info.provider == "tavily"
|
||||
assert info.supported is True
|
||||
assert info.used is None
|
||||
assert info.limit is None
|
||||
|
||||
def test_no_breakdown_fields(self):
|
||||
data = {"account": {"plan_usage": 5, "plan_limit": 50}}
|
||||
info = _parse_tavily_usage(data)
|
||||
assert info.search_used is None
|
||||
assert info.extract_used is None
|
||||
assert info.crawl_used is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_search_usage routing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetchSearchUsageRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_duckduckgo_returns_unsupported(self):
|
||||
info = await fetch_search_usage("duckduckgo")
|
||||
assert info.provider == "duckduckgo"
|
||||
assert info.supported is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_searxng_returns_unsupported(self):
|
||||
info = await fetch_search_usage("searxng")
|
||||
assert info.supported is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jina_returns_unsupported(self):
|
||||
info = await fetch_search_usage("jina")
|
||||
assert info.supported is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brave_returns_unsupported(self):
|
||||
info = await fetch_search_usage("brave")
|
||||
assert info.provider == "brave"
|
||||
assert info.supported is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_provider_returns_unsupported(self):
|
||||
info = await fetch_search_usage("some_unknown_provider")
|
||||
assert info.supported is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_no_api_key_returns_error(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
# Ensure TAVILY_API_KEY is not set
|
||||
import os
|
||||
os.environ.pop("TAVILY_API_KEY", None)
|
||||
info = await fetch_search_usage("tavily", api_key=None)
|
||||
assert info.provider == "tavily"
|
||||
assert info.supported is True
|
||||
assert info.error is not None
|
||||
assert "not configured" in info.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_success(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"account": {
|
||||
"current_plan": "Researcher",
|
||||
"plan_usage": 142,
|
||||
"plan_limit": 1000,
|
||||
"search_usage": 120,
|
||||
"extract_usage": 15,
|
||||
"crawl_usage": 7,
|
||||
},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
info = await fetch_search_usage("tavily", api_key="test-key")
|
||||
|
||||
assert info.provider == "tavily"
|
||||
assert info.supported is True
|
||||
assert info.error is None
|
||||
assert info.used == 142
|
||||
assert info.limit == 1000
|
||||
assert info.remaining == 858
|
||||
assert info.search_used == 120
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_http_error(self):
|
||||
import httpx
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"401", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
info = await fetch_search_usage("tavily", api_key="bad-key")
|
||||
|
||||
assert info.supported is True
|
||||
assert info.error == "HTTP 401"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_network_error(self):
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("timeout"))
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
info = await fetch_search_usage("tavily", api_key="test-key")
|
||||
|
||||
assert info.supported is True
|
||||
assert info.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_name_case_insensitive(self):
|
||||
info = await fetch_search_usage("Tavily", api_key=None)
|
||||
assert info.provider == "tavily"
|
||||
assert info.supported is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_status_content integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildStatusContentWithSearchUsage:
|
||||
_BASE_KWARGS = dict(
|
||||
version="0.1.0",
|
||||
model="claude-opus-4-5",
|
||||
start_time=1_000_000.0,
|
||||
last_usage={"prompt_tokens": 1000, "completion_tokens": 200},
|
||||
context_window_tokens=65536,
|
||||
session_msg_count=5,
|
||||
context_tokens_estimate=3000,
|
||||
)
|
||||
|
||||
def test_no_search_usage_unchanged(self):
|
||||
"""Omitting search_usage_text keeps existing behaviour."""
|
||||
content = build_status_content(**self._BASE_KWARGS)
|
||||
assert "🔍" not in content
|
||||
assert "Web Search" not in content
|
||||
|
||||
def test_search_usage_none_unchanged(self):
|
||||
content = build_status_content(**self._BASE_KWARGS, search_usage_text=None)
|
||||
assert "🔍" not in content
|
||||
|
||||
def test_search_usage_appended(self):
|
||||
usage_text = "🔍 Web Search: tavily\n Usage: 142 / 1000 requests"
|
||||
content = build_status_content(**self._BASE_KWARGS, search_usage_text=usage_text)
|
||||
assert "🔍 Web Search: tavily" in content
|
||||
assert "142 / 1000" in content
|
||||
|
||||
def test_existing_fields_still_present(self):
|
||||
usage_text = "🔍 Web Search: duckduckgo\n Usage tracking: not available"
|
||||
content = build_status_content(**self._BASE_KWARGS, search_usage_text=usage_text)
|
||||
# Original fields must still be present
|
||||
assert "nanobot v0.1.0" in content
|
||||
assert "claude-opus-4-5" in content
|
||||
assert "1000 in / 200 out" in content
|
||||
# New field appended
|
||||
assert "duckduckgo" in content
|
||||
|
||||
def test_full_tavily_in_status(self):
|
||||
info = SearchUsageInfo(
|
||||
provider="tavily",
|
||||
supported=True,
|
||||
used=142,
|
||||
limit=1000,
|
||||
remaining=858,
|
||||
reset_date="2026-05-01",
|
||||
search_used=120,
|
||||
extract_used=15,
|
||||
crawl_used=7,
|
||||
)
|
||||
content = build_status_content(**self._BASE_KWARGS, search_usage_text=info.format())
|
||||
assert "142 / 1000" in content
|
||||
assert "858" in content
|
||||
assert "2026-05-01" in content
|
||||
assert "Search: 120" in content
|
||||
Reference in New Issue
Block a user