test: add tests for identity block, fact extraction, and audit log
- test_oauth_identity_block: verify identity block is included in API requests even when system=None (covers fix in3f2684d) - test_mem0_extract_facts: verify extract_facts passes thinking_budget=0 to provider.chat() (covers fix in76d5a73) - test_session_audit_log: verify save() creates append-only audit log with markers and message preservation (covers feat in2ab6494) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user