nanobot/tests/providers/test_callbacks.py

153 lines
5.1 KiB
Python

# tests/providers/test_callbacks.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from pathlib import Path
def test_callback_module_exists():
from nanobot.providers.callbacks import ConversationCallback
assert ConversationCallback is not None
def test_callback_inherits_custom_logger():
"""Verify callback follows LiteLLM's CustomLogger pattern."""
from nanobot.providers.callbacks import ConversationCallback
from litellm.integrations.custom_logger import CustomLogger
cb = ConversationCallback()
assert isinstance(cb, CustomLogger)
assert hasattr(cb, "async_log_success_event")
assert hasattr(cb, "async_log_failure_event")
@pytest.mark.asyncio
async def test_async_log_success_event_extracts_full_messages():
"""Verify callback captures full message history from kwargs["messages"]."""
from nanobot.providers.callbacks import ConversationCallback
cb = ConversationCallback()
# Simulate a subagent conversation with tool calls
kwargs = {
"model": "anthropic/claude-opus-4-5",
"messages": [
{"role": "system", "content": "You are a subagent..."},
{"role": "user", "content": "Read test.txt and summarize it"},
{"role": "assistant", "content": "I'll read the file.", "tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": '{"path": "test.txt"}'}}
]},
{"role": "tool", "tool_call_id": "call_1", "name": "read_file", "content": "Hello World"},
{"role": "assistant", "content": "The file contains: Hello World"},
],
"litellm_params": {
"metadata": {
"session_key": "subagent:abc12345",
"agent_type": "subagent",
"parent_session": "cli:direct",
"task_id": "abc12345",
}
},
"response_cost": 0.0025,
"cache_hit": False,
}
response_obj = MagicMock()
response_obj.choices = [MagicMock()]
response_obj.choices[0].message.content = "The file contains: Hello World"
response_obj.choices[0].finish_reason = "stop"
response_obj.usage = MagicMock()
response_obj.usage.prompt_tokens = 150
response_obj.usage.completion_tokens = 20
response_obj.usage.total_tokens = 170
# Should not raise
await cb.async_log_success_event(kwargs, response_obj, 0.0, 0.5)
@pytest.mark.asyncio
async def test_callback_writes_to_jsonl(tmp_path):
"""Verify callback writes trace to JSONL file."""
from nanobot.providers.callbacks import ConversationCallback
jsonl_path = tmp_path / "traces.jsonl"
cb = ConversationCallback(jsonl_path=jsonl_path)
kwargs = {
"model": "test-model",
"messages": [{"role": "user", "content": "hi"}],
"litellm_params": {"metadata": {"session_key": "test:123"}},
"response_cost": 0.001,
}
response_obj = MagicMock()
response_obj.choices = [MagicMock()]
response_obj.choices[0].message.content = "hello"
response_obj.choices[0].finish_reason = "stop"
response_obj.usage = MagicMock()
response_obj.usage.prompt_tokens = 5
response_obj.usage.completion_tokens = 5
response_obj.usage.total_tokens = 10
await cb.async_log_success_event(kwargs, response_obj, 0.0, 0.1)
# Verify file was written
assert jsonl_path.exists()
import json
with open(jsonl_path) as f:
entry = json.loads(f.readline())
assert entry["model"] == "test-model"
assert len(entry["messages"]) == 1
assert entry["metadata"]["session_key"] == "test:123"
@pytest.mark.asyncio
async def test_async_log_failure_event_extracts_fields():
"""Verify async_log_failure_event captures error details from kwargs."""
from nanobot.providers.callbacks import ConversationCallback
cb = ConversationCallback()
kwargs = {
"model": "anthropic/claude-opus-4-5",
"litellm_params": {
"metadata": {
"session_key": "cli:direct",
"agent_type": "main",
}
},
"exception": ValueError("Rate limit exceeded"),
}
# Should not raise
await cb.async_log_failure_event(kwargs, None, 0.0, 1.0)
@pytest.mark.asyncio
async def test_async_log_failure_event_writes_to_jsonl(tmp_path):
"""Verify failure event is written to JSONL with correct structure."""
from nanobot.providers.callbacks import ConversationCallback
jsonl_path = tmp_path / "traces.jsonl"
cb = ConversationCallback(jsonl_path=jsonl_path)
kwargs = {
"model": "test-model",
"litellm_params": {"metadata": {"session_key": "test:456"}},
"exception": ValueError("API error"),
}
await cb.async_log_failure_event(kwargs, None, 0.0, 0.5)
# Verify file was written
assert jsonl_path.exists()
import json
with open(jsonl_path) as f:
entry = json.loads(f.readline())
assert entry["event_type"] == "failure"
assert entry["model"] == "test-model"
assert entry["metadata"]["session_key"] == "test:456"
assert entry["error"] == "API error"
assert entry["error_type"] == "ValueError"
assert "duration_ms" in entry