The 21 the automatic pass could not make on its own. `ruff check` and `ruff format --check` are both clean now; typecheck is still red and is next. `in_reasoning` in chat/service.py was a complete state machine that nothing read: initialised False, set True when a reasoning delta arrived, set False when the summary ended — three assignments, zero reads. Ruff reported one at a time, and removing each revealed the next, so what looked like a single stray variable took three passes to bottom out. The branches themselves do real work and are untouched; only the flag is gone. Four `raise HTTPException` inside `except` blocks now chain with `from e`. Until now a failure while handling an error was indistinguishable from the error, which matters most in exactly the situation where the traceback is all you have. In biographer/tools.py the binding was unused but the call is not: MemoryType() is called for the ValueError it raises on an invalid name. The binding is gone and the call and its comment stay, because dropping the line would have removed the validation. The rest are unused bindings in tests where the assertions are on something else (call_args, mostly), plus three unused loop variables and an isinstance tuple. One correction to my own work: removing a dead comprehension in test_error_handling.py left an `if` block with nothing but comments in it, which is a SyntaxError. Ruff caught it immediately. The block now says what the test actually pins — that the stream parses without crashing, which reaching that line demonstrates — rather than computing a list nobody asserts on. `make test` is intermittent here, and it is not this change. test_tatlock_tool_call_logging_calculator failed in two of five full runs across both HEAD and this branch, and passes in the other three; it also fails in isolation at HEAD while passing in isolation here. Order- or timing-dependent. Recorded rather than chased, since tests are not gated in this repo yet. Co-Authored-By: Claude <noreply@anthropic.com>
589 lines
19 KiB
Python
589 lines
19 KiB
Python
"""
|
|
Tests for Phase 6 advanced features.
|
|
|
|
Tests:
|
|
- Parameter validation (reasoning effort, max_output_tokens, stop sequences)
|
|
- Stop sequence detection and enforcement
|
|
- Max tokens enforcement
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from httpx import AsyncClient
|
|
from pydantic import ValidationError
|
|
|
|
from src.responses.schemas import ResponseRequest
|
|
from src.responses.streaming import StreamingCoordinator
|
|
|
|
# ============================================================================
|
|
# Parameter Validation Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_temperature_validation():
|
|
"""Test temperature parameter validation."""
|
|
# Valid temperatures
|
|
valid_temps = [0.0, 0.5, 1.0, 1.5, 2.0]
|
|
for temp in valid_temps:
|
|
request = ResponseRequest(
|
|
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], temperature=temp
|
|
)
|
|
assert request.temperature == temp
|
|
|
|
# Invalid temperatures
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
temperature=-0.1, # Too low
|
|
)
|
|
assert "temperature" in str(exc_info.value).lower()
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
temperature=2.1, # Too high
|
|
)
|
|
assert "temperature" in str(exc_info.value).lower()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_reasoning_effort_validation():
|
|
"""Test reasoning.effort parameter validation."""
|
|
# Valid effort levels
|
|
valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
|
for effort in valid_efforts:
|
|
request = ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
reasoning={"effort": effort, "summary": "auto"},
|
|
)
|
|
assert request.reasoning["effort"] == effort
|
|
|
|
# Invalid effort level
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
reasoning={"effort": "invalid", "summary": "auto"},
|
|
)
|
|
assert "reasoning.effort" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_reasoning_summary_validation():
|
|
"""Test reasoning.summary parameter validation."""
|
|
# Valid summary values
|
|
valid_summaries = ["auto", "off"]
|
|
for summary in valid_summaries:
|
|
request = ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
reasoning={"effort": "medium", "summary": summary},
|
|
)
|
|
assert request.reasoning["summary"] == summary
|
|
|
|
# Invalid summary value
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
reasoning={"effort": "medium", "summary": "invalid"},
|
|
)
|
|
assert "reasoning.summary" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_max_output_tokens_validation():
|
|
"""Test max_output_tokens parameter validation."""
|
|
# Valid values
|
|
request = ResponseRequest(
|
|
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=100
|
|
)
|
|
assert request.max_output_tokens == 100
|
|
|
|
# None is valid (unlimited)
|
|
request = ResponseRequest(
|
|
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=None
|
|
)
|
|
assert request.max_output_tokens is None
|
|
|
|
# Invalid: zero or negative
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], max_output_tokens=0
|
|
)
|
|
assert "max_output_tokens" in str(exc_info.value)
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
max_output_tokens=-10,
|
|
)
|
|
assert "max_output_tokens" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_stop_sequences_validation():
|
|
"""Test stop sequences parameter validation."""
|
|
# Valid: up to 4 stop sequences
|
|
for num_seqs in range(1, 5):
|
|
stop_seqs = [f"stop{i}" for i in range(num_seqs)]
|
|
request = ResponseRequest(
|
|
model="lorem-tester", input=[{"role": "user", "content": "Hello"}], stop=stop_seqs
|
|
)
|
|
assert request.stop == stop_seqs
|
|
|
|
# Invalid: more than 4 stop sequences
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
stop=["stop1", "stop2", "stop3", "stop4", "stop5"], # 5 sequences
|
|
)
|
|
assert "4 stop sequences" in str(exc_info.value)
|
|
|
|
# Invalid: empty string in stop sequences
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ResponseRequest(
|
|
model="lorem-tester",
|
|
input=[{"role": "user", "content": "Hello"}],
|
|
stop=["stop1", ""], # Empty string
|
|
)
|
|
assert "non-empty" in str(exc_info.value).lower()
|
|
|
|
|
|
# ============================================================================
|
|
# Stop Sequence Enforcement Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_stop_sequence_detection_helper():
|
|
"""Test stop sequence detection helper method."""
|
|
coordinator = StreamingCoordinator()
|
|
|
|
# No stop sequences
|
|
found, text = coordinator._check_stop_sequence("Hello world", None)
|
|
assert found is False
|
|
assert text == "Hello world"
|
|
|
|
# Stop sequence not present
|
|
found, text = coordinator._check_stop_sequence("Hello world", ["STOP", "END"])
|
|
assert found is False
|
|
assert text == "Hello world"
|
|
|
|
# Stop sequence found
|
|
found, text = coordinator._check_stop_sequence("Hello STOP this should not appear", ["STOP"])
|
|
assert found is True
|
|
assert text == "Hello "
|
|
|
|
# Multiple stop sequences, first one wins
|
|
found, text = coordinator._check_stop_sequence("Hello STOP this END that", ["STOP", "END"])
|
|
assert found is True
|
|
assert text == "Hello "
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_stop_sequence_in_streaming(async_client: AsyncClient):
|
|
"""Test stop sequence enforcement during streaming."""
|
|
# We'll use lorem-tester which generates predictable text
|
|
# The agent generates lorem ipsum text, so we use a stop sequence
|
|
# that's likely to appear
|
|
request_data = {
|
|
"model": "lorem-tester",
|
|
"input": [{"role": "user", "content": "Generate long text"}],
|
|
"stop": ["dolor"], # Common word in lorem ipsum
|
|
"stream": True,
|
|
}
|
|
|
|
chunks_received = []
|
|
async with async_client.stream(
|
|
"POST",
|
|
"/v1/responses",
|
|
json=request_data,
|
|
timeout=20.0,
|
|
) as response:
|
|
assert response.status_code == 200
|
|
|
|
async for line in response.aiter_lines():
|
|
if not line.strip():
|
|
continue
|
|
|
|
if line.startswith("data: "):
|
|
data_str = line[6:].strip()
|
|
if data_str != "[DONE]":
|
|
try:
|
|
chunk = json.loads(data_str)
|
|
chunks_received.append(chunk)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Should have received chunks and stopped early
|
|
# (Can't verify exact stop behavior with random lorem ipsum,
|
|
# but test ensures no errors occur)
|
|
assert len(chunks_received) > 0
|
|
|
|
|
|
# ============================================================================
|
|
# Max Tokens Enforcement Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_max_tokens_check_helper():
|
|
"""Test max tokens check helper method."""
|
|
coordinator = StreamingCoordinator()
|
|
|
|
# No limit
|
|
assert coordinator._check_max_tokens(100, None) is False
|
|
|
|
# Under limit
|
|
assert coordinator._check_max_tokens(50, 100) is False
|
|
|
|
# At limit
|
|
assert coordinator._check_max_tokens(100, 100) is True
|
|
|
|
# Over limit
|
|
assert coordinator._check_max_tokens(150, 100) is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_token_counting_helper():
|
|
"""Test approximate token counting."""
|
|
coordinator = StreamingCoordinator()
|
|
|
|
# Empty string
|
|
assert coordinator._count_tokens_approx("") == 0
|
|
|
|
# ~4 chars per token
|
|
text = "Hello world" # 11 chars
|
|
tokens = coordinator._count_tokens_approx(text)
|
|
assert tokens == 2 # 11 // 4 = 2
|
|
|
|
text = "A" * 100 # 100 chars
|
|
tokens = coordinator._count_tokens_approx(text)
|
|
assert tokens == 25 # 100 // 4 = 25
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_max_tokens_in_streaming(async_client: AsyncClient):
|
|
"""Test max tokens enforcement during streaming."""
|
|
# Set very low max_output_tokens to force early stop
|
|
request_data = {
|
|
"model": "lorem-tester",
|
|
"input": [{"role": "user", "content": "Generate text"}],
|
|
"max_output_tokens": 5, # Very low limit
|
|
"stream": True,
|
|
}
|
|
|
|
chunks_received = []
|
|
text_chunks = []
|
|
|
|
async with async_client.stream(
|
|
"POST",
|
|
"/v1/responses",
|
|
json=request_data,
|
|
timeout=20.0,
|
|
) as response:
|
|
assert response.status_code == 200
|
|
|
|
async for line in response.aiter_lines():
|
|
if not line.strip():
|
|
continue
|
|
|
|
if line.startswith("event: "):
|
|
line[7:].strip()
|
|
elif line.startswith("data: "):
|
|
data_str = line[6:].strip()
|
|
if data_str != "[DONE]":
|
|
try:
|
|
chunk = json.loads(data_str)
|
|
chunks_received.append(chunk)
|
|
|
|
# Collect text deltas
|
|
if "delta" in chunk:
|
|
text_chunks.append(chunk["delta"])
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Should have stopped early due to token limit
|
|
total_text = "".join(text_chunks)
|
|
# With max_output_tokens=5, total text should be small
|
|
# (Approximate: 5 tokens * 4 chars ≈ 20 chars)
|
|
assert len(total_text) < 100 # Reasonable upper bound
|
|
|
|
|
|
# ============================================================================
|
|
# Combined Features Test
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_combined_validation(client: TestClient):
|
|
"""Test combined parameter validation in actual request."""
|
|
# Valid request with all advanced features
|
|
request_data = {
|
|
"model": "lorem-tester",
|
|
"input": [{"role": "user", "content": "Hello"}],
|
|
"temperature": 0.7,
|
|
"max_output_tokens": 100,
|
|
"stop": ["STOP", "END"],
|
|
"reasoning": {"effort": "high", "summary": "auto"},
|
|
"stream": False,
|
|
}
|
|
|
|
response = client.post("/v1/responses", json=request_data)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_invalid_combined_parameters(client: TestClient):
|
|
"""Test that invalid parameters are rejected."""
|
|
# Invalid temperature
|
|
request_data = {
|
|
"model": "lorem-tester",
|
|
"input": [{"role": "user", "content": "Hello"}],
|
|
"temperature": 3.0, # Too high
|
|
"stream": False,
|
|
}
|
|
|
|
response = client.post("/v1/responses", json=request_data)
|
|
assert response.status_code == 422
|
|
data = response.json()
|
|
assert "error" in data
|
|
assert data["error"]["type"] == "invalid_request_error"
|
|
|
|
|
|
# ============================================================================
|
|
# Streaming Delta Calculation Tests (No Duplication)
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_delta_calculation_no_duplication():
|
|
"""
|
|
Test that StreamingCoordinator correctly calculates deltas when agent
|
|
yields accumulated text multiple times (PydanticAI pattern).
|
|
|
|
This test prevents the duplication bug where the same text was
|
|
streamed multiple times because we weren't computing deltas correctly.
|
|
"""
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
from src.agents.base import AgentInterface, OutputItem
|
|
|
|
# Create a mock agent that simulates PydanticAI's behavior
|
|
# (yielding accumulated text, not deltas)
|
|
class MockStreamingAgent(AgentInterface):
|
|
async def generate_response(
|
|
self,
|
|
messages: list[dict],
|
|
reasoning: dict | None = None,
|
|
tools: list[dict] | None = None,
|
|
temperature: float = 1.0,
|
|
max_tokens: int | None = None,
|
|
stop: list[str] | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[OutputItem, None]:
|
|
"""
|
|
Simulate PydanticAI streaming behavior:
|
|
- Yields accumulated text, not deltas
|
|
- Multiple yields with status="in_progress"
|
|
- Final yield with status="completed"
|
|
"""
|
|
msg_id = "msg_test_123"
|
|
|
|
# Simulate incremental accumulation like PydanticAI does
|
|
accumulated_texts = [
|
|
"Hello",
|
|
"Hello world",
|
|
"Hello world how",
|
|
"Hello world how are",
|
|
"Hello world how are you",
|
|
]
|
|
|
|
for text in accumulated_texts:
|
|
yield OutputItem(
|
|
type="message",
|
|
id=msg_id,
|
|
role="assistant",
|
|
content=[{"type": "output_text", "text": text, "annotations": []}],
|
|
status="in_progress",
|
|
)
|
|
|
|
# Final message
|
|
yield OutputItem(
|
|
type="message",
|
|
id=msg_id,
|
|
role="assistant",
|
|
content=[
|
|
{"type": "output_text", "text": "Hello world how are you", "annotations": []}
|
|
],
|
|
status="completed",
|
|
)
|
|
|
|
async def supports_tools(self) -> bool:
|
|
return False
|
|
|
|
async def supports_reasoning(self) -> bool:
|
|
return False
|
|
|
|
async def get_capabilities(self) -> dict:
|
|
return {"streaming": True, "reasoning": False, "tools": False}
|
|
|
|
# Register the mock agent
|
|
import time
|
|
|
|
from src.agents.registry import ModelRegistry
|
|
|
|
ModelRegistry.MODELS["mock-streaming"] = {
|
|
"agent_class": MockStreamingAgent,
|
|
"description": "Mock streaming agent for testing",
|
|
"created": int(time.time()),
|
|
"owned_by": "test",
|
|
}
|
|
|
|
try:
|
|
# Create a test request
|
|
request = ResponseRequest(
|
|
model="mock-streaming", input=[{"role": "user", "content": "Test"}], stream=True
|
|
)
|
|
|
|
# Stream the response
|
|
coordinator = StreamingCoordinator()
|
|
collected_deltas = []
|
|
|
|
async for event in coordinator.stream_response(request):
|
|
if event.event == "response.output_text.delta":
|
|
collected_deltas.append(event.delta)
|
|
|
|
# Reconstruct the full text from deltas
|
|
full_text = "".join(collected_deltas)
|
|
|
|
# Verify no duplication - the text should appear exactly once
|
|
assert full_text.count("Hello") == 1, "Text 'Hello' should appear exactly once"
|
|
assert full_text.count("world") == 1, "Text 'world' should appear exactly once"
|
|
assert full_text.count("how") == 1, "Text 'how' should appear exactly once"
|
|
assert full_text.count("are") == 1, "Text 'are' should appear exactly once"
|
|
assert full_text.count("you") == 1, "Text 'you' should appear exactly once"
|
|
|
|
# Verify the reconstructed text is correct (no trailing space with chunk streaming)
|
|
expected_text = "Hello world how are you"
|
|
assert full_text == expected_text, f"Expected '{expected_text}', got '{full_text}'"
|
|
|
|
finally:
|
|
# Clean up
|
|
del ModelRegistry.MODELS["mock-streaming"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_with_multiple_message_items():
|
|
"""
|
|
Test that coordinator handles multiple message OutputItems correctly,
|
|
only streaming the delta between each one.
|
|
"""
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
from src.agents.base import AgentInterface, OutputItem
|
|
|
|
class MockMultiMessageAgent(AgentInterface):
|
|
async def generate_response(
|
|
self,
|
|
messages: list[dict],
|
|
reasoning: dict | None = None,
|
|
tools: list[dict] | None = None,
|
|
temperature: float = 1.0,
|
|
max_tokens: int | None = None,
|
|
stop: list[str] | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[OutputItem, None]:
|
|
"""Yield multiple in_progress messages with accumulated text."""
|
|
# First chunk
|
|
yield OutputItem(
|
|
type="message",
|
|
id="msg_1",
|
|
role="assistant",
|
|
content=[{"type": "output_text", "text": "The answer is", "annotations": []}],
|
|
status="in_progress",
|
|
)
|
|
|
|
# Second chunk (more text accumulated)
|
|
yield OutputItem(
|
|
type="message",
|
|
id="msg_1",
|
|
role="assistant",
|
|
content=[{"type": "output_text", "text": "The answer is 42", "annotations": []}],
|
|
status="in_progress",
|
|
)
|
|
|
|
# Final chunk
|
|
yield OutputItem(
|
|
type="message",
|
|
id="msg_1",
|
|
role="assistant",
|
|
content=[{"type": "output_text", "text": "The answer is 42", "annotations": []}],
|
|
status="completed",
|
|
)
|
|
|
|
async def supports_tools(self) -> bool:
|
|
return False
|
|
|
|
async def supports_reasoning(self) -> bool:
|
|
return False
|
|
|
|
async def get_capabilities(self) -> dict:
|
|
return {"streaming": True, "reasoning": False, "tools": False}
|
|
|
|
# Register mock agent
|
|
import time
|
|
|
|
from src.agents.registry import ModelRegistry
|
|
|
|
ModelRegistry.MODELS["mock-multi"] = {
|
|
"agent_class": MockMultiMessageAgent,
|
|
"description": "Mock multi-message agent for testing",
|
|
"created": int(time.time()),
|
|
"owned_by": "test",
|
|
}
|
|
|
|
try:
|
|
request = ResponseRequest(
|
|
model="mock-multi",
|
|
input=[{"role": "user", "content": "What is the answer?"}],
|
|
stream=True,
|
|
)
|
|
|
|
coordinator = StreamingCoordinator()
|
|
collected_deltas = []
|
|
|
|
async for event in coordinator.stream_response(request):
|
|
if event.event == "response.output_text.delta":
|
|
collected_deltas.append(event.delta)
|
|
|
|
full_text = "".join(collected_deltas)
|
|
|
|
# Should only see "The answer is 42" once, not repeated
|
|
assert "The answer is 42" in full_text
|
|
# Count occurrences - should only appear once
|
|
assert full_text.count("The") == 1
|
|
assert full_text.count("answer") == 1
|
|
assert full_text.count("42") == 1
|
|
|
|
finally:
|
|
# Clean up
|
|
del ModelRegistry.MODELS["mock-multi"]
|