mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-02 09:22:36 +00:00
153 lines
5.1 KiB
Python
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
|