Build and Push / build (release) Successful in 53s
- Convert booleans to strings for Redis hset (Redis doesn't accept bool) - Extract capability from delegate_to_X tool names for tracking - Use loop_scope="module" for pytest-asyncio module-scoped fixtures - Add note about using venv for tests in AGENTS.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
643 lines
22 KiB
Python
643 lines
22 KiB
Python
"""
|
|
End-to-end API tests that make real HTTP requests.
|
|
|
|
These tests hit the actual running server and test the full stack:
|
|
- HTTP request/response handling
|
|
- Steward preprocessing
|
|
- Tool execution
|
|
- Response formatting
|
|
"""
|
|
import pytest
|
|
import pytest_asyncio
|
|
import httpx
|
|
from typing import AsyncGenerator
|
|
|
|
# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh)
|
|
BASE_URL = "http://localhost:8777"
|
|
API_TIMEOUT = 120.0 # 120 second timeout for LLM calls
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="module", scope="module")
|
|
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
"""HTTP client for making requests."""
|
|
async with httpx.AsyncClient(base_url=BASE_URL, timeout=API_TIMEOUT) as client:
|
|
yield client
|
|
|
|
|
|
class TestChatCompletionsE2E:
|
|
"""End-to-end tests for /v1/chat/completions endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_simple_calculation(self, client: httpx.AsyncClient):
|
|
"""Test that a math request triggers calculator tool."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 144 divided by 12?"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify response structure
|
|
assert data["object"] == "chat.completion"
|
|
assert data["model"] == "Tatlock"
|
|
assert len(data["choices"]) == 1
|
|
|
|
# Verify response content
|
|
message = data["choices"][0]["message"]
|
|
assert message["role"] == "assistant"
|
|
content = message["content"]
|
|
|
|
# Should contain Steward's analysis in <think> tags
|
|
assert "<think>" in content
|
|
assert "</think>" in content
|
|
|
|
# Should contain the answer (12) - just check the number appears
|
|
assert "12" in content, f"Expected answer '12' not found in: {content}"
|
|
|
|
# Should show calculator was used - check for tool indicator
|
|
# Tool calls show up with 🧮 emoji when logged
|
|
has_calculator_indicator = "🧮" in content
|
|
|
|
# Verify usage stats
|
|
assert "usage" in data
|
|
assert data["usage"]["total_tokens"] > 0
|
|
|
|
print(f"✓ Calculator test passed. Found '12' in response. Tool indicator: {has_calculator_indicator}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_web_search(self, client: httpx.AsyncClient):
|
|
"""Test that a search request can trigger web search tool."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "Search for the current population of Tokyo"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify response structure
|
|
assert data["object"] == "chat.completion"
|
|
assert len(data["choices"]) == 1
|
|
|
|
message = data["choices"][0]["message"]
|
|
content = message["content"]
|
|
|
|
# Should contain Steward's analysis
|
|
assert "<think>" in content
|
|
assert "</think>" in content
|
|
|
|
# Should mention Tokyo or population (flexible - LLM output varies)
|
|
assert "Tokyo" in content or "million" in content
|
|
|
|
# Check if search was used (🔍 emoji indicates search tool call)
|
|
has_search_indicator = "🔍" in content
|
|
|
|
print(f"✓ Search test passed. Search indicator present: {has_search_indicator}")
|
|
|
|
@pytest.mark.skip(reason="Flaky: hits edge case with conversation history formatting")
|
|
@pytest.mark.asyncio
|
|
async def test_multi_turn_conversation(self, client: httpx.AsyncClient):
|
|
"""Test multi-turn conversation maintains context."""
|
|
# First turn: Ask a question
|
|
response1 = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 15 times 4?"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response1.status_code == 200
|
|
data1 = response1.json()
|
|
message1 = data1["choices"][0]["message"]["content"]
|
|
|
|
# Should contain "60" somewhere in response
|
|
assert "60" in message1, f"Expected '60' not found in: {message1}"
|
|
|
|
# Second turn: Follow-up question referencing previous answer
|
|
response2 = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 15 times 4?"},
|
|
{"role": "assistant", "content": message1},
|
|
{"role": "user", "content": "Now add 20 to that result."}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response2.status_code == 200
|
|
data2 = response2.json()
|
|
message2 = data2["choices"][0]["message"]["content"]
|
|
|
|
# Should have Steward analysis
|
|
assert "<think>" in message2
|
|
|
|
# Should either have the answer "80" OR show calculation attempt (LLM variance)
|
|
has_answer = "80" in message2
|
|
has_calculation = "60" in message2 and "20" in message2
|
|
assert has_answer or has_calculation, f"Expected '80' or calculation in: {message2}"
|
|
|
|
print(f"✓ Multi-turn test passed. Answer found: {has_answer}, Calculation shown: {has_calculation}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculation_and_search(self, client: httpx.AsyncClient):
|
|
"""Test request requiring both calculator and search."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "Calculate the square root of 256"
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
message = data["choices"][0]["message"]["content"]
|
|
|
|
# Should contain Steward's analysis
|
|
assert "<think>" in message
|
|
assert "</think>" in message
|
|
|
|
# Should calculate sqrt(256) = 16 (just check number appears)
|
|
assert "16" in message, f"Expected '16' (sqrt of 256) not found in: {message}"
|
|
|
|
# Check for calculator tool indicator
|
|
has_calculator = "🧮" in message
|
|
|
|
print(f"✓ Calculation test passed. Found '16'. Calculator indicator: {has_calculator}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_simple_greeting_no_tools(self, client: httpx.AsyncClient):
|
|
"""Test that simple greetings don't trigger unnecessary tools."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "Hello, how are you?"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
message = data["choices"][0]["message"]["content"]
|
|
|
|
# Should still have Steward analysis
|
|
assert "<think>" in message
|
|
|
|
# Should NOT show tool usage indicators (no calculations or searches needed)
|
|
has_tools = "🧮" in message or "🔍" in message
|
|
|
|
# Should get some response (exact wording varies)
|
|
assert len(message) > 20, "Response should have content"
|
|
|
|
print(f"✓ Greeting test passed. No tools needed (tools used: {has_tools})")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_date_time_query(self, client: httpx.AsyncClient):
|
|
"""Test date/time queries trigger datetime tools."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is today's date?"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
message = data["choices"][0]["message"]["content"]
|
|
|
|
# Should contain Steward analysis
|
|
assert "<think>" in message
|
|
|
|
# Check for datetime tool indicator (🕐 emoji)
|
|
has_datetime = "🕐" in message
|
|
|
|
# Should contain some date/time information (flexible - varies in format)
|
|
import re
|
|
has_date = (
|
|
re.search(r'\d{4}', message) or # Year
|
|
re.search(r'\d{1,2}', message) or # Day/month number
|
|
re.search(r'(January|February|March|April|May|June|July|August|September|October|November|December)', message, re.IGNORECASE) or
|
|
"today" in message.lower()
|
|
)
|
|
|
|
assert has_date, f"Expected date/time information in: {message}"
|
|
print(f"✓ Date/time test passed. Datetime tool indicator: {has_datetime}")
|
|
|
|
|
|
class TestResponsesAPIE2E:
|
|
"""End-to-end tests for /v1/responses endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_with_reasoning(self, client: httpx.AsyncClient):
|
|
"""Test Responses API with reasoning output."""
|
|
response = await client.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "Tatlock",
|
|
"input": [
|
|
{"role": "user", "content": "Calculate 25 times 16"}
|
|
],
|
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify response structure
|
|
assert data["object"] == "response"
|
|
assert data["model"] == "Tatlock"
|
|
assert data["status"] == "completed"
|
|
|
|
# Should have output items
|
|
assert len(data["output"]) >= 2 # At least reasoning + message
|
|
|
|
# First item should be Steward's reasoning
|
|
reasoning_item = data["output"][0]
|
|
assert reasoning_item["type"] == "reasoning"
|
|
assert "summary" in reasoning_item
|
|
assert "🎩" in str(reasoning_item["summary"]) or "Steward" in str(reasoning_item["summary"])
|
|
|
|
# Last item should be message
|
|
message_item = data["output"][-1]
|
|
assert message_item["type"] == "message"
|
|
assert message_item["role"] == "assistant"
|
|
|
|
# Should contain the answer (400) somewhere in response
|
|
message_content = message_item["content"][0]["text"]
|
|
assert "400" in message_content, f"Expected '400' (25*16) not found in: {message_content}"
|
|
|
|
# Verify usage stats
|
|
assert "usage" in data
|
|
assert data["usage"]["total_tokens"] > 0
|
|
|
|
print(f"✓ Responses API test passed. Found '400' with Steward reasoning.")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_multi_turn(self, client: httpx.AsyncClient):
|
|
"""Test Responses API with conversation history."""
|
|
response = await client.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "Tatlock",
|
|
"input": [
|
|
{"role": "user", "content": "What is 7 times 8?"},
|
|
{"role": "assistant", "content": "Certainly, sir. 7 times 8 equals 56."},
|
|
{"role": "user", "content": "Double that number."}
|
|
],
|
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should have Steward reasoning (wording may vary)
|
|
reasoning_item = data["output"][0]
|
|
reasoning_text = " ".join(reasoning_item["summary"])
|
|
|
|
# Steward analysis should be present (exact wording varies with LLM)
|
|
assert "🎩" in reasoning_text or "Steward" in reasoning_text
|
|
assert "tatlock_core" in reasoning_text.lower() or "calculat" in reasoning_text.lower()
|
|
|
|
# Should calculate 112 (56 * 2)
|
|
message_item = data["output"][-1]
|
|
message_content = message_item["content"][0]["text"]
|
|
assert "112" in message_content
|
|
|
|
|
|
class TestStreamingE2E:
|
|
"""End-to-end tests for streaming endpoints."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_streaming(self, client: httpx.AsyncClient):
|
|
"""Test streaming chat completions."""
|
|
async with client.stream(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 9 times 7?"}
|
|
],
|
|
"stream": True
|
|
}
|
|
) as response:
|
|
assert response.status_code == 200
|
|
|
|
chunks = []
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("data: "):
|
|
data_str = line[6:] # Remove "data: " prefix
|
|
if data_str == "[DONE]":
|
|
break
|
|
|
|
import json
|
|
chunk = json.loads(data_str)
|
|
chunks.append(chunk)
|
|
|
|
# Should have received multiple chunks
|
|
assert len(chunks) > 0
|
|
|
|
# First chunk should have role
|
|
assert chunks[0]["choices"][0]["delta"]["role"] == "assistant"
|
|
|
|
# Should have received Steward's reasoning (in <think> tags)
|
|
full_content = "".join(
|
|
chunk["choices"][0]["delta"].get("content", "") or ""
|
|
for chunk in chunks
|
|
)
|
|
assert "<think>" in full_content
|
|
assert "</think>" in full_content
|
|
|
|
# Should contain answer (63)
|
|
assert "63" in full_content
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""End-to-end tests for error handling."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_model(self, client: httpx.AsyncClient):
|
|
"""Test request with non-existent model."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "nonexistent-model",
|
|
"messages": [
|
|
{"role": "user", "content": "Hello"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
data = response.json()
|
|
assert "error" in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_messages(self, client: httpx.AsyncClient):
|
|
"""Test request with missing required field."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
# Missing "messages" field
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "error" in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_temperature(self, client: httpx.AsyncClient):
|
|
"""Test request with out-of-range temperature."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "Hello"}
|
|
],
|
|
"temperature": 5.0 # Max is 2.0
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "error" in data
|
|
|
|
|
|
class TestChatResponsesWrapper:
|
|
"""Tests to verify Chat Completions properly wraps Responses API."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_format_matches_spec(self, client: httpx.AsyncClient):
|
|
"""Test that Responses API matches OpenAI Responses format spec."""
|
|
response = await client.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "Tatlock",
|
|
"input": [
|
|
{"role": "user", "content": "Calculate 13 times 9"}
|
|
],
|
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify OpenAI Responses format
|
|
assert data["object"] == "response"
|
|
assert data["model"] == "Tatlock"
|
|
assert data["status"] == "completed"
|
|
assert "id" in data
|
|
assert "created_at" in data
|
|
assert "output" in data
|
|
assert isinstance(data["output"], list)
|
|
|
|
# Verify output items structure
|
|
for item in data["output"]:
|
|
assert "type" in item
|
|
assert "id" in item
|
|
assert "status" in item
|
|
assert item["type"] in ["reasoning", "message", "function_call"]
|
|
|
|
if item["type"] == "reasoning":
|
|
assert "summary" in item
|
|
assert isinstance(item["summary"], list)
|
|
|
|
elif item["type"] == "message":
|
|
assert "role" in item
|
|
assert "content" in item
|
|
assert isinstance(item["content"], list)
|
|
for content_item in item["content"]:
|
|
assert "type" in content_item
|
|
assert "text" in content_item
|
|
|
|
# Verify usage stats
|
|
assert "usage" in data
|
|
assert "input_tokens" in data["usage"]
|
|
assert "output_tokens" in data["usage"]
|
|
assert "total_tokens" in data["usage"]
|
|
|
|
print("✓ Responses API format matches OpenAI Responses spec")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_format_matches_openai_spec(self, client: httpx.AsyncClient):
|
|
"""Test that Chat Completions response matches OpenAI spec."""
|
|
response = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 5 plus 3?"}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify OpenAI Chat Completions format
|
|
assert data["object"] == "chat.completion"
|
|
assert data["model"] == "Tatlock"
|
|
assert "id" in data
|
|
assert "created" in data
|
|
assert "choices" in data
|
|
assert len(data["choices"]) == 1
|
|
|
|
choice = data["choices"][0]
|
|
assert choice["index"] == 0
|
|
assert choice["message"]["role"] == "assistant"
|
|
assert isinstance(choice["message"]["content"], str)
|
|
assert choice["finish_reason"] == "stop"
|
|
|
|
# Verify usage stats
|
|
assert "usage" in data
|
|
assert "prompt_tokens" in data["usage"]
|
|
assert "completion_tokens" in data["usage"]
|
|
assert "total_tokens" in data["usage"]
|
|
|
|
print("✓ Chat Completions format matches OpenAI spec")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_streaming_format_matches_openai_spec(self, client: httpx.AsyncClient):
|
|
"""Test that streaming Chat Completions matches OpenAI SSE spec."""
|
|
async with client.stream(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "Tatlock",
|
|
"messages": [
|
|
{"role": "user", "content": "Count to 3"}
|
|
],
|
|
"stream": True
|
|
}
|
|
) as response:
|
|
assert response.status_code == 200
|
|
|
|
chunks = []
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("data: "):
|
|
data_str = line[6:]
|
|
if data_str == "[DONE]":
|
|
break
|
|
|
|
import json
|
|
chunk = json.loads(data_str)
|
|
chunks.append(chunk)
|
|
|
|
# Verify each chunk matches OpenAI format
|
|
assert chunk["object"] == "chat.completion.chunk"
|
|
assert chunk["model"] == "Tatlock"
|
|
assert "id" in chunk
|
|
assert "created" in chunk
|
|
assert "choices" in chunk
|
|
assert len(chunk["choices"]) == 1
|
|
|
|
choice = chunk["choices"][0]
|
|
assert choice["index"] == 0
|
|
assert "delta" in choice
|
|
|
|
# First chunk should have role
|
|
assert chunks[0]["choices"][0]["delta"]["role"] == "assistant"
|
|
|
|
# Should have content chunks
|
|
has_content = any(
|
|
"content" in chunk["choices"][0]["delta"]
|
|
for chunk in chunks
|
|
)
|
|
assert has_content
|
|
|
|
print(f"✓ Streaming format matches OpenAI spec ({len(chunks)} chunks)")
|
|
|
|
|
|
class TestStewardIntegration:
|
|
"""Tests specifically for Steward preprocessing behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_steward_recommends_calculator(self, client: httpx.AsyncClient):
|
|
"""Verify Steward recommends calculator for math."""
|
|
response = await client.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "Tatlock",
|
|
"input": [
|
|
{"role": "user", "content": "Calculate 123 times 456"}
|
|
],
|
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Check Steward's reasoning
|
|
reasoning_item = data["output"][0]
|
|
reasoning_text = " ".join(reasoning_item["summary"]).lower()
|
|
|
|
# Should mention tatlock_core or calculation capability
|
|
assert "tatlock_core" in reasoning_text or "calculat" in reasoning_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_steward_context_awareness(self, client: httpx.AsyncClient):
|
|
"""Verify Steward detects conversation context."""
|
|
response = await client.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "Tatlock",
|
|
"input": [
|
|
{"role": "user", "content": "My favorite number is 42"},
|
|
{"role": "assistant", "content": "Noted, sir. 42 is an excellent choice."},
|
|
{"role": "user", "content": "What was that number again?"}
|
|
],
|
|
"reasoning": {"effort": "medium", "summary": "auto"}
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Check Steward's reasoning is present
|
|
reasoning_item = data["output"][0]
|
|
reasoning_text = " ".join(reasoning_item["summary"])
|
|
|
|
# Steward analysis should be present (exact wording varies)
|
|
assert "🎩" in reasoning_text or "Steward" in reasoning_text
|
|
|
|
# Should get some response (LLM may or may not recall "42" depending on context interpretation)
|
|
message_item = data["output"][-1]
|
|
message_content = message_item["content"][0]["text"]
|
|
assert len(message_content) > 20 # Has meaningful response
|