test: add tests for identity block, fact extraction, and audit log
Build Nanobot OAuth / build (push) Successful in 47s
Build Nanobot OAuth / cleanup (push) Successful in 0s

- test_oauth_identity_block: verify identity block is included in API
  requests even when system=None (covers fix in 3f2684d)
- test_mem0_extract_facts: verify extract_facts passes thinking_budget=0
  to provider.chat() (covers fix in 76d5a73)
- test_session_audit_log: verify save() creates append-only audit log
  with markers and message preservation (covers feat in 2ab6494)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 12:49:23 +01:00
parent 76d5a73cc7
commit 86fe3a4749
3 changed files with 368 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
"""Test mem0 fact extraction calls provider with thinking disabled."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from pathlib import Path
from nanobot.providers.base import LLMResponse
@pytest.fixture
def mock_provider():
provider = AsyncMock()
provider.chat = AsyncMock(return_value=LLMResponse(
content='{"facts": ["user likes Python", "user works on nanobot"]}',
finish_reason="end_turn",
))
return provider
@pytest.fixture
def mem0_store(tmp_path):
"""Create a Mem0MemoryStore with mocked mem0 dependency."""
# We can't import Mem0MemoryStore at module level because it requires
# the mem0 package. Instead, we test extract_facts as a standalone method
# by constructing a minimal instance.
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
store = Mem0MemoryStore(workspace=tmp_path)
return store
except ImportError:
pytest.skip("mem0 not installed")
@pytest.mark.asyncio
async def test_extract_facts_passes_thinking_budget_zero(mock_provider):
"""extract_facts must pass thinking_budget=0 to provider.chat().
Without this, the provider inherits its instance default (e.g. 10000),
causing the model to spend tokens on thinking instead of outputting JSON.
"""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
# Create a minimal instance without full mem0 init
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": "I like Python programming"},
{"role": "assistant", "content": "That's great! Python is versatile."},
]
facts = await store.extract_facts(messages, mock_provider, "claude-sonnet-4-5")
# Verify provider.chat was called with thinking_budget=0
mock_provider.chat.assert_called_once()
call_kwargs = mock_provider.chat.call_args.kwargs
assert call_kwargs["thinking_budget"] == 0
@pytest.mark.asyncio
async def test_extract_facts_returns_parsed_facts(mock_provider):
"""extract_facts should parse JSON response into a list of fact strings."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": "I like Python programming"},
]
facts = await store.extract_facts(messages, mock_provider, "claude-sonnet-4-5")
assert facts == ["user likes Python", "user works on nanobot"]
@pytest.mark.asyncio
async def test_extract_facts_handles_empty_response():
"""extract_facts should return empty list when provider returns no content."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
provider = AsyncMock()
provider.chat = AsyncMock(return_value=LLMResponse(
content="",
finish_reason="end_turn",
))
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [{"role": "user", "content": "Hello there"}]
facts = await store.extract_facts(messages, provider, "claude-sonnet-4-5")
assert facts == []
@pytest.mark.asyncio
async def test_extract_facts_skips_empty_messages():
"""extract_facts should return empty list when all messages have empty content."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
provider = AsyncMock()
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": ""},
{"role": "assistant", "content": ""},
]
facts = await store.extract_facts(messages, provider, "claude-sonnet-4-5")
assert facts == []
# Provider should not be called when there's no content
provider.chat.assert_not_called()
+102
View File
@@ -0,0 +1,102 @@
"""Test that the Anthropic OAuth identity block is always included in API requests."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.oauth_utils import get_claude_code_system_prefix
IDENTITY_TEXT = get_claude_code_system_prefix()
@pytest.fixture
def provider():
return AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-opus-4-5",
)
def _mock_response(status_code=200, json_data=None):
"""Create a mock httpx.Response."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.headers = {}
resp.text = ""
resp.json.return_value = json_data or {
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
}
return resp
@pytest.mark.asyncio
async def test_identity_block_present_with_system_prompt(provider):
"""When a system prompt is provided, identity block is the first system block."""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
system="You are a helpful assistant.",
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
assert len(system_blocks) == 2
assert system_blocks[0]["type"] == "text"
assert system_blocks[0]["text"] == IDENTITY_TEXT
assert system_blocks[1]["text"] == "You are a helpful assistant."
@pytest.mark.asyncio
async def test_identity_block_present_without_system_prompt(provider):
"""When no system prompt is provided, identity block is still included.
This is the critical fix: extract_facts and similar calls pass system=None,
but Anthropic requires the identity block for OAuth tokens.
"""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "extract facts"}],
system=None,
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
assert len(system_blocks) == 1
assert system_blocks[0]["type"] == "text"
assert system_blocks[0]["text"] == IDENTITY_TEXT
@pytest.mark.asyncio
async def test_identity_block_present_with_empty_string_system(provider):
"""Empty string system prompt should still include the identity block."""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
system="",
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
# Empty string is falsy, so should go through the else branch
assert len(system_blocks) == 1
assert system_blocks[0]["text"] == IDENTITY_TEXT
+137
View File
@@ -0,0 +1,137 @@
"""Test SessionManager audit log functionality."""
import json
import pytest
from nanobot.session.manager import Session, SessionManager
@pytest.fixture
def session_manager(tmp_path):
return SessionManager(workspace=tmp_path)
@pytest.fixture
def session():
s = Session(key="telegram:12345")
s.add_message("user", "Hello")
s.add_message("assistant", "Hi there!")
return s
def test_save_creates_audit_file(session_manager, session):
"""SessionManager.save() should create a monthly audit log file."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
assert len(audit_files) == 1
assert "telegram_12345.audit." in audit_files[0].name
def test_audit_file_contains_save_marker(session_manager, session):
"""Audit log should start with a save_marker line containing metadata."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
marker = json.loads(lines[0])
assert marker["_type"] == "save_marker"
assert marker["message_count"] == 2
assert "timestamp" in marker
def test_audit_file_contains_all_messages(session_manager, session):
"""Audit log should contain all session messages after the save marker."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
# Line 0 = save_marker, lines 1-2 = messages
assert len(lines) == 3
msg1 = json.loads(lines[1])
msg2 = json.loads(lines[2])
assert msg1["role"] == "user"
assert msg1["content"] == "Hello"
assert msg2["role"] == "assistant"
assert msg2["content"] == "Hi there!"
def test_audit_file_is_append_only(session_manager, session):
"""Multiple saves should append to the same audit file, not overwrite."""
session_manager.save(session)
# Add another message and save again
session.add_message("user", "How are you?")
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
assert len(audit_files) == 1 # Same file
lines = audit_files[0].read_text().strip().split("\n")
# First save: 1 marker + 2 messages = 3 lines
# Second save: 1 marker + 3 messages = 4 lines
# Total: 7 lines
assert len(lines) == 7
# Both save markers present
markers = [json.loads(l) for l in lines if json.loads(l).get("_type") == "save_marker"]
assert len(markers) == 2
assert markers[0]["message_count"] == 2
assert markers[1]["message_count"] == 3
def test_audit_preserves_message_fields(session_manager):
"""Audit log should preserve all message fields including reasoning_content."""
session = Session(key="test:preserve")
session.add_raw_message({
"role": "assistant",
"content": "thinking response",
"reasoning_content": [{"type": "thinking", "thinking": "deep thoughts"}],
"timestamp": "2026-03-22T12:00:00",
})
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
msg = json.loads(lines[1])
assert msg["reasoning_content"] == [{"type": "thinking", "thinking": "deep thoughts"}]
def test_audit_failure_does_not_break_save(session_manager, session, tmp_path):
"""If audit logging fails, the main session save should still succeed.
_append_audit has its own try/except, so internal failures are caught.
We simulate a realistic failure by making the sessions dir read-only
for audit file creation.
"""
# First save works (creates both session file and audit file)
session_manager.save(session)
path = session_manager._get_session_path(session.key)
assert path.exists()
# Remove audit files and make a blocking file at the audit path
# so the next audit open("a") fails
for af in session_manager.sessions_dir.glob("*.audit.*.jsonl"):
af.unlink()
# Create a directory where the audit file should be — open() will fail
from datetime import datetime
now = datetime.now()
bad_path = session_manager.sessions_dir / f"telegram_12345.audit.{now:%Y-%m}.jsonl"
bad_path.mkdir()
# Second save should succeed despite audit failure
session.add_message("user", "another message")
session_manager.save(session)
# Session file should still be written correctly
with open(path) as f:
first_line = json.loads(f.readline())
assert first_line["_type"] == "metadata"