API / kiro-gateway /tests /unit /test_streaming_core.py
sshinmen's picture
Clean deploy to HF Space
bf9e111
Raw
History Blame Contribute Delete
67.3 kB
# -*- coding: utf-8 -*-
"""
Unit tests for streaming_core module.
Tests for:
- KiroEvent dataclass
- StreamResult dataclass
- FirstTokenTimeoutError exception
- parse_kiro_stream() function
- collect_stream_to_result() function
- calculate_tokens_from_context_usage() function
"""
import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from dataclasses import asdict
from kiro.streaming_core import (
KiroEvent,
StreamResult,
FirstTokenTimeoutError,
parse_kiro_stream,
collect_stream_to_result,
calculate_tokens_from_context_usage,
stream_with_first_token_retry,
_process_chunk,
)
# ==================================================================================================
# Fixtures
# ==================================================================================================
@pytest.fixture
def mock_model_cache():
"""Mock for ModelInfoCache."""
cache = MagicMock()
cache.get_max_input_tokens.return_value = 200000
return cache
@pytest.fixture
def mock_response():
"""Mock for httpx.Response."""
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
@pytest.fixture
def mock_parser():
"""Mock for AwsEventStreamParser."""
parser = MagicMock()
parser.feed.return_value = []
parser.get_tool_calls.return_value = []
return parser
# ==================================================================================================
# Tests for KiroEvent dataclass
# ==================================================================================================
class TestKiroEvent:
"""Tests for KiroEvent dataclass."""
def test_creates_content_event(self):
"""
What it does: Creates a content event with text.
Goal: Verify KiroEvent can represent content events.
"""
print("Action: Creating content event...")
event = KiroEvent(type="content", content="Hello, world!")
print(f"Comparing type: Expected 'content', Got '{event.type}'")
assert event.type == "content"
print(f"Comparing content: Expected 'Hello, world!', Got '{event.content}'")
assert event.content == "Hello, world!"
assert event.thinking_content is None
assert event.tool_use is None
print("✓ Content event created correctly")
def test_creates_thinking_event(self):
"""
What it does: Creates a thinking event with reasoning content.
Goal: Verify KiroEvent can represent thinking events.
"""
print("Action: Creating thinking event...")
event = KiroEvent(
type="thinking",
thinking_content="Let me think...",
is_first_thinking_chunk=True,
is_last_thinking_chunk=False
)
print(f"Comparing type: Expected 'thinking', Got '{event.type}'")
assert event.type == "thinking"
print(f"Comparing thinking_content: Expected 'Let me think...', Got '{event.thinking_content}'")
assert event.thinking_content == "Let me think..."
assert event.is_first_thinking_chunk is True
assert event.is_last_thinking_chunk is False
print("✓ Thinking event created correctly")
def test_creates_tool_use_event(self):
"""
What it does: Creates a tool_use event with tool data.
Goal: Verify KiroEvent can represent tool use events.
"""
print("Action: Creating tool_use event...")
tool_data = {
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Moscow"}'}
}
event = KiroEvent(type="tool_use", tool_use=tool_data)
print(f"Comparing type: Expected 'tool_use', Got '{event.type}'")
assert event.type == "tool_use"
print(f"Comparing tool_use: Expected {tool_data}, Got {event.tool_use}")
assert event.tool_use == tool_data
print("✓ Tool use event created correctly")
def test_creates_usage_event(self):
"""
What it does: Creates a usage event with metering data.
Goal: Verify KiroEvent can represent usage events.
"""
print("Action: Creating usage event...")
usage_data = {"credits": 0.001}
event = KiroEvent(type="usage", usage=usage_data)
print(f"Comparing type: Expected 'usage', Got '{event.type}'")
assert event.type == "usage"
print(f"Comparing usage: Expected {usage_data}, Got {event.usage}")
assert event.usage == usage_data
print("✓ Usage event created correctly")
def test_creates_context_usage_event(self):
"""
What it does: Creates a context_usage event with percentage.
Goal: Verify KiroEvent can represent context usage events.
"""
print("Action: Creating context_usage event...")
event = KiroEvent(type="context_usage", context_usage_percentage=5.5)
print(f"Comparing type: Expected 'context_usage', Got '{event.type}'")
assert event.type == "context_usage"
print(f"Comparing context_usage_percentage: Expected 5.5, Got {event.context_usage_percentage}")
assert event.context_usage_percentage == 5.5
print("✓ Context usage event created correctly")
def test_default_values(self):
"""
What it does: Verifies default values for optional fields.
Goal: Ensure all optional fields default to None/False.
"""
print("Action: Creating minimal event...")
event = KiroEvent(type="content")
print("Checking default values...")
assert event.content is None
assert event.thinking_content is None
assert event.tool_use is None
assert event.usage is None
assert event.context_usage_percentage is None
assert event.is_first_thinking_chunk is False
assert event.is_last_thinking_chunk is False
print("✓ All default values are correct")
# ==================================================================================================
# Tests for StreamResult dataclass
# ==================================================================================================
class TestStreamResult:
"""Tests for StreamResult dataclass."""
def test_creates_empty_result(self):
"""
What it does: Creates an empty StreamResult.
Goal: Verify default values are correct.
"""
print("Action: Creating empty StreamResult...")
result = StreamResult()
print("Checking default values...")
assert result.content == ""
assert result.thinking_content == ""
assert result.tool_calls == []
assert result.usage is None
assert result.context_usage_percentage is None
print("✓ Empty StreamResult created correctly")
def test_creates_result_with_content(self):
"""
What it does: Creates StreamResult with content.
Goal: Verify content is stored correctly.
"""
print("Action: Creating StreamResult with content...")
result = StreamResult(content="Hello, world!")
print(f"Comparing content: Expected 'Hello, world!', Got '{result.content}'")
assert result.content == "Hello, world!"
print("✓ StreamResult with content created correctly")
def test_creates_result_with_tool_calls(self):
"""
What it does: Creates StreamResult with tool calls.
Goal: Verify tool calls are stored correctly.
"""
print("Action: Creating StreamResult with tool calls...")
tool_calls = [
{"id": "call_1", "function": {"name": "func1"}},
{"id": "call_2", "function": {"name": "func2"}}
]
result = StreamResult(tool_calls=tool_calls)
print(f"Comparing tool_calls count: Expected 2, Got {len(result.tool_calls)}")
assert len(result.tool_calls) == 2
assert result.tool_calls[0]["id"] == "call_1"
print("✓ StreamResult with tool calls created correctly")
def test_creates_result_with_usage(self):
"""
What it does: Creates StreamResult with usage data.
Goal: Verify usage is stored correctly.
"""
print("Action: Creating StreamResult with usage...")
usage = {"credits": 0.002}
result = StreamResult(usage=usage)
print(f"Comparing usage: Expected {usage}, Got {result.usage}")
assert result.usage == usage
print("✓ StreamResult with usage created correctly")
def test_creates_full_result(self):
"""
What it does: Creates StreamResult with all fields.
Goal: Verify all fields work together.
"""
print("Action: Creating full StreamResult...")
result = StreamResult(
content="Response text",
thinking_content="Thinking...",
tool_calls=[{"id": "call_1"}],
usage={"credits": 0.001},
context_usage_percentage=3.5
)
print("Checking all fields...")
assert result.content == "Response text"
assert result.thinking_content == "Thinking..."
assert len(result.tool_calls) == 1
assert result.usage == {"credits": 0.001}
assert result.context_usage_percentage == 3.5
print("✓ Full StreamResult created correctly")
# ==================================================================================================
# Tests for FirstTokenTimeoutError
# ==================================================================================================
class TestFirstTokenTimeoutError:
"""Tests for FirstTokenTimeoutError exception."""
def test_creates_exception_with_message(self):
"""
What it does: Creates exception with custom message.
Goal: Verify exception message is stored correctly.
"""
print("Action: Creating FirstTokenTimeoutError...")
error = FirstTokenTimeoutError("No response within 30 seconds")
print(f"Comparing message: Expected 'No response within 30 seconds', Got '{str(error)}'")
assert str(error) == "No response within 30 seconds"
print("✓ Exception created correctly")
def test_exception_is_catchable(self):
"""
What it does: Verifies exception can be caught.
Goal: Ensure exception inherits from Exception.
"""
print("Action: Raising and catching FirstTokenTimeoutError...")
with pytest.raises(FirstTokenTimeoutError) as exc_info:
raise FirstTokenTimeoutError("Timeout!")
print(f"Caught exception: {exc_info.value}")
assert "Timeout!" in str(exc_info.value)
print("✓ Exception is catchable")
def test_exception_inherits_from_exception(self):
"""
What it does: Verifies inheritance chain.
Goal: Ensure proper exception hierarchy.
"""
print("Action: Checking inheritance...")
error = FirstTokenTimeoutError("Test")
assert isinstance(error, Exception)
print("✓ FirstTokenTimeoutError inherits from Exception")
# ==================================================================================================
# Tests for parse_kiro_stream()
# ==================================================================================================
class TestParseKiroStream:
"""Tests for parse_kiro_stream() function."""
@pytest.mark.asyncio
async def test_parses_content_events(self, mock_response, mock_parser):
"""
What it does: Parses content events from Kiro stream.
Goal: Verify content events are yielded correctly.
"""
print("Setup: Mock parser to return content events...")
mock_parser.feed.return_value = [
{"type": "content", "data": "Hello"},
{"type": "content", "data": " World"}
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
print(f"Received {len(events)} events")
content_events = [e for e in events if e.type == "content"]
print(f"Content events: {len(content_events)}")
assert len(content_events) == 2
assert content_events[0].content == "Hello"
assert content_events[1].content == " World"
print("✓ Content events parsed correctly")
@pytest.mark.asyncio
async def test_parses_usage_events(self, mock_response, mock_parser):
"""
What it does: Parses usage events from Kiro stream.
Goal: Verify usage events are yielded correctly.
"""
print("Setup: Mock parser to return usage event...")
mock_parser.feed.return_value = [
{"type": "usage", "data": {"credits": 0.001}}
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
print(f"Received {len(events)} events")
usage_events = [e for e in events if e.type == "usage"]
assert len(usage_events) == 1
assert usage_events[0].usage == {"credits": 0.001}
print("✓ Usage events parsed correctly")
@pytest.mark.asyncio
async def test_parses_context_usage_events(self, mock_response, mock_parser):
"""
What it does: Parses context_usage events from Kiro stream.
Goal: Verify context usage percentage is yielded correctly.
"""
print("Setup: Mock parser to return context_usage event...")
mock_parser.feed.return_value = [
{"type": "context_usage", "data": 5.5}
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
print(f"Received {len(events)} events")
context_events = [e for e in events if e.type == "context_usage"]
assert len(context_events) == 1
assert context_events[0].context_usage_percentage == 5.5
print("✓ Context usage events parsed correctly")
@pytest.mark.asyncio
async def test_yields_tool_calls_at_end(self, mock_response, mock_parser):
"""
What it does: Yields tool calls collected during parsing.
Goal: Verify tool calls are yielded as events.
"""
print("Setup: Mock parser with tool calls...")
mock_parser.feed.return_value = [{"type": "content", "data": "text"}]
mock_parser.get_tool_calls.return_value = [
{"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
print(f"Received {len(events)} events")
tool_events = [e for e in events if e.type == "tool_use"]
assert len(tool_events) == 1
assert tool_events[0].tool_use["id"] == "call_1"
print("✓ Tool calls yielded correctly")
@pytest.mark.asyncio
async def test_raises_timeout_on_first_token(self, mock_response):
"""
What it does: Raises FirstTokenTimeoutError on timeout.
Goal: Verify timeout handling for first token.
"""
print("Setup: Mock response that times out...")
async def mock_aiter_bytes():
yield b'chunk'
mock_response.aiter_bytes = mock_aiter_bytes
async def mock_wait_for_timeout(*args, **kwargs):
raise asyncio.TimeoutError()
print("Action: Parsing stream with timeout...")
with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_timeout):
with pytest.raises(FirstTokenTimeoutError) as exc_info:
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
pass
print(f"Caught exception: {exc_info.value}")
assert "30" in str(exc_info.value)
print("✓ FirstTokenTimeoutError raised on timeout")
@pytest.mark.asyncio
async def test_handles_empty_response(self, mock_response):
"""
What it does: Handles empty response gracefully.
Goal: Verify no events yielded for empty response.
"""
print("Setup: Mock empty response...")
async def mock_aiter_bytes():
return
yield # Make it a generator
mock_response.aiter_bytes = mock_aiter_bytes
# Mock wait_for to raise StopAsyncIteration (empty response)
async def mock_wait_for_empty(*args, **kwargs):
raise StopAsyncIteration()
print("Action: Parsing empty stream...")
events = []
with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_empty):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 0
print("✓ Empty response handled correctly")
@pytest.mark.asyncio
async def test_handles_generator_exit(self, mock_response, mock_parser):
"""
What it does: Handles GeneratorExit gracefully.
Goal: Verify client disconnect is handled.
"""
print("Setup: Mock response that raises GeneratorExit...")
async def mock_aiter_bytes():
yield b'chunk1'
raise GeneratorExit()
mock_response.aiter_bytes = mock_aiter_bytes
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
print("Action: Parsing stream with GeneratorExit...")
events = []
generator_exit_raised = False
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
try:
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
except GeneratorExit:
generator_exit_raised = True
print(f"GeneratorExit raised: {generator_exit_raised}")
assert generator_exit_raised
print("✓ GeneratorExit handled correctly")
# ==================================================================================================
# Tests for _process_chunk()
# ==================================================================================================
class TestProcessChunk:
"""Tests for _process_chunk() helper function."""
@pytest.mark.asyncio
async def test_processes_content_event(self, mock_parser):
"""
What it does: Processes content event from chunk.
Goal: Verify content is converted to KiroEvent.
"""
print("Setup: Mock parser with content event...")
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
print("Action: Processing chunk...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', None):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 1
assert events[0].type == "content"
assert events[0].content == "Hello"
print("✓ Content event processed correctly")
@pytest.mark.asyncio
async def test_processes_usage_event(self, mock_parser):
"""
What it does: Processes usage event from chunk.
Goal: Verify usage is converted to KiroEvent.
"""
print("Setup: Mock parser with usage event...")
mock_parser.feed.return_value = [{"type": "usage", "data": {"credits": 0.001}}]
print("Action: Processing chunk...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', None):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 1
assert events[0].type == "usage"
assert events[0].usage == {"credits": 0.001}
print("✓ Usage event processed correctly")
@pytest.mark.asyncio
async def test_processes_context_usage_event(self, mock_parser):
"""
What it does: Processes context_usage event from chunk.
Goal: Verify context usage is converted to KiroEvent.
"""
print("Setup: Mock parser with context_usage event...")
mock_parser.feed.return_value = [{"type": "context_usage", "data": 7.5}]
print("Action: Processing chunk...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', None):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 1
assert events[0].type == "context_usage"
assert events[0].context_usage_percentage == 7.5
print("✓ Context usage event processed correctly")
@pytest.mark.asyncio
async def test_processes_multiple_events(self, mock_parser):
"""
What it does: Processes multiple events from single chunk.
Goal: Verify all events are yielded.
"""
print("Setup: Mock parser with multiple events...")
mock_parser.feed.return_value = [
{"type": "content", "data": "Hello"},
{"type": "content", "data": " World"},
{"type": "usage", "data": {"credits": 0.001}}
]
print("Action: Processing chunk...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', None):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 3
assert events[0].type == "content"
assert events[1].type == "content"
assert events[2].type == "usage"
print("✓ Multiple events processed correctly")
@pytest.mark.asyncio
async def test_processes_with_thinking_parser(self, mock_parser):
"""
What it does: Processes content through thinking parser.
Goal: Verify thinking parser integration.
"""
print("Setup: Mock parser and thinking parser...")
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
mock_thinking_parser = MagicMock()
mock_thinking_parser.feed.return_value = MagicMock(
thinking_content=None,
regular_content="Hello",
is_first_thinking_chunk=False,
is_last_thinking_chunk=False
)
print("Action: Processing chunk with thinking parser...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', mock_thinking_parser):
events.append(event)
print(f"Received {len(events)} events")
assert len(events) == 1
assert events[0].type == "content"
assert events[0].content == "Hello"
print("✓ Thinking parser integration works correctly")
@pytest.mark.asyncio
async def test_yields_thinking_content(self, mock_parser):
"""
What it does: Yields thinking content from thinking parser.
Goal: Verify thinking events are created.
"""
print("Setup: Mock parser and thinking parser with thinking content...")
mock_parser.feed.return_value = [{"type": "content", "data": "<thinking>Let me think</thinking>"}]
mock_thinking_parser = MagicMock()
mock_thinking_parser.feed.return_value = MagicMock(
thinking_content="Let me think",
regular_content=None,
is_first_thinking_chunk=True,
is_last_thinking_chunk=True
)
mock_thinking_parser.process_for_output.return_value = "Let me think"
print("Action: Processing chunk with thinking content...")
events = []
async for event in _process_chunk(mock_parser, b'chunk', mock_thinking_parser):
events.append(event)
print(f"Received {len(events)} events")
thinking_events = [e for e in events if e.type == "thinking"]
assert len(thinking_events) == 1
assert thinking_events[0].thinking_content == "Let me think"
print("✓ Thinking content yielded correctly")
# ==================================================================================================
# Tests for collect_stream_to_result()
# ==================================================================================================
class TestCollectStreamToResult:
"""Tests for collect_stream_to_result() function."""
@pytest.mark.asyncio
async def test_collects_content(self, mock_response, mock_parser):
"""
What it does: Collects content from stream.
Goal: Verify content is accumulated correctly.
"""
print("Setup: Mock parser with content events...")
mock_parser.feed.return_value = [
{"type": "content", "data": "Hello"},
{"type": "content", "data": " World"}
]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Collecting stream...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected content: '{result.content}'")
assert result.content == "Hello World"
print("✓ Content collected correctly")
@pytest.mark.asyncio
async def test_collects_tool_calls(self, mock_response, mock_parser):
"""
What it does: Collects tool calls from stream.
Goal: Verify tool calls are accumulated correctly.
"""
print("Setup: Mock parser with tool calls...")
mock_parser.feed.return_value = [{"type": "content", "data": "text"}]
mock_parser.get_tool_calls.return_value = [
{"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Collecting stream...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected tool calls: {len(result.tool_calls)}")
assert len(result.tool_calls) == 1
assert result.tool_calls[0]["id"] == "call_1"
print("✓ Tool calls collected correctly")
@pytest.mark.asyncio
async def test_collects_usage(self, mock_response, mock_parser):
"""
What it does: Collects usage from stream.
Goal: Verify usage is stored correctly.
"""
print("Setup: Mock parser with usage event...")
mock_parser.feed.return_value = [
{"type": "content", "data": "text"},
{"type": "usage", "data": {"credits": 0.002}}
]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Collecting stream...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected usage: {result.usage}")
assert result.usage == {"credits": 0.002}
print("✓ Usage collected correctly")
@pytest.mark.asyncio
async def test_collects_context_usage_percentage(self, mock_response, mock_parser):
"""
What it does: Collects context usage percentage from stream.
Goal: Verify context usage is stored correctly.
"""
print("Setup: Mock parser with context_usage event...")
mock_parser.feed.return_value = [
{"type": "content", "data": "text"},
{"type": "context_usage", "data": 8.5}
]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Collecting stream...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected context_usage_percentage: {result.context_usage_percentage}")
assert result.context_usage_percentage == 8.5
print("✓ Context usage percentage collected correctly")
@pytest.mark.asyncio
async def test_collects_thinking_content(self, mock_response, mock_parser):
"""
What it does: Collects thinking content from stream.
Goal: Verify thinking content is accumulated correctly.
"""
print("Setup: Mock parser with thinking content...")
# We need to mock the thinking parser behavior
mock_parser.feed.return_value = [{"type": "content", "data": "thinking text"}]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
# Create mock events that include thinking
mock_events = [
KiroEvent(type="thinking", thinking_content="Let me think..."),
KiroEvent(type="content", content="Here is my answer")
]
async def mock_parse_kiro_stream(*args, **kwargs):
for event in mock_events:
yield event
print("Action: Collecting stream with thinking...")
with patch('kiro.streaming_core.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected thinking_content: '{result.thinking_content}'")
print(f"Collected content: '{result.content}'")
assert result.thinking_content == "Let me think..."
assert result.content == "Here is my answer"
print("✓ Thinking content collected correctly")
@pytest.mark.asyncio
async def test_deduplicates_bracket_tool_calls(self, mock_response, mock_parser):
"""
What it does: Deduplicates bracket-style tool calls.
Goal: Verify duplicate tool calls are removed.
"""
print("Setup: Mock parser with tool calls and bracket tool calls...")
mock_parser.feed.return_value = [{"type": "content", "data": "text"}]
mock_parser.get_tool_calls.return_value = [
{"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}
]
bracket_tool_calls = [
{"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}, # Duplicate
{"id": "call_2", "function": {"name": "func2", "arguments": "{}"}} # New
]
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Collecting stream with duplicates...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=bracket_tool_calls):
with patch('kiro.streaming_core.deduplicate_tool_calls') as mock_dedup:
mock_dedup.return_value = [
{"id": "call_1", "function": {"name": "func1", "arguments": "{}"}},
{"id": "call_2", "function": {"name": "func2", "arguments": "{}"}}
]
result = await collect_stream_to_result(mock_response, first_token_timeout=30)
print(f"Collected tool calls: {len(result.tool_calls)}")
assert len(result.tool_calls) == 2
print("✓ Tool calls deduplicated correctly")
# ==================================================================================================
# Tests for calculate_tokens_from_context_usage()
# ==================================================================================================
class TestCalculateTokensFromContextUsage:
"""Tests for calculate_tokens_from_context_usage() function."""
def test_calculates_tokens_from_percentage(self, mock_model_cache):
"""
What it does: Calculates tokens from context usage percentage.
Goal: Verify token calculation is correct.
"""
print("Setup: Context usage 10% with 200000 max tokens...")
context_usage_percentage = 10.0
completion_tokens = 100
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
# 10% of 200000 = 20000 total tokens
# prompt_tokens = 20000 - 100 = 19900
print(f"Comparing total_tokens: Expected 20000, Got {total_tokens}")
assert total_tokens == 20000
print(f"Comparing prompt_tokens: Expected 19900, Got {prompt_tokens}")
assert prompt_tokens == 19900
assert prompt_source == "subtraction"
assert total_source == "API Kiro"
print("✓ Tokens calculated correctly")
def test_handles_zero_percentage(self, mock_model_cache):
"""
What it does: Handles zero context usage percentage.
Goal: Verify fallback behavior for zero percentage.
"""
print("Setup: Context usage 0%...")
context_usage_percentage = 0.0
completion_tokens = 100
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
print(f"Comparing prompt_tokens: Expected 0, Got {prompt_tokens}")
assert prompt_tokens == 0
print(f"Comparing total_tokens: Expected 100, Got {total_tokens}")
assert total_tokens == 100
assert prompt_source == "unknown"
assert total_source == "tiktoken"
print("✓ Zero percentage handled correctly")
def test_handles_none_percentage(self, mock_model_cache):
"""
What it does: Handles None context usage percentage.
Goal: Verify fallback behavior for None percentage.
"""
print("Setup: Context usage None...")
context_usage_percentage = None
completion_tokens = 100
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
print(f"Comparing prompt_tokens: Expected 0, Got {prompt_tokens}")
assert prompt_tokens == 0
print(f"Comparing total_tokens: Expected 100, Got {total_tokens}")
assert total_tokens == 100
assert prompt_source == "unknown"
assert total_source == "tiktoken"
print("✓ None percentage handled correctly")
def test_prevents_negative_prompt_tokens(self, mock_model_cache):
"""
What it does: Prevents negative prompt tokens.
Goal: Verify prompt_tokens is never negative.
"""
print("Setup: Very small context usage with large completion...")
context_usage_percentage = 0.01 # 0.01% of 200000 = 20 total tokens
completion_tokens = 100 # More than total!
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
print(f"Comparing prompt_tokens: Expected >= 0, Got {prompt_tokens}")
assert prompt_tokens >= 0
print("✓ Negative prompt tokens prevented")
def test_uses_model_specific_max_tokens(self, mock_model_cache):
"""
What it does: Uses model-specific max input tokens.
Goal: Verify model cache is queried correctly.
"""
print("Setup: Different max tokens for model...")
mock_model_cache.get_max_input_tokens.return_value = 100000 # Different from default
context_usage_percentage = 10.0
completion_tokens = 100
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-haiku-3"
)
# 10% of 100000 = 10000 total tokens
print(f"Comparing total_tokens: Expected 10000, Got {total_tokens}")
assert total_tokens == 10000
# Verify model cache was called with correct model
mock_model_cache.get_max_input_tokens.assert_called_with("claude-haiku-3")
print("✓ Model-specific max tokens used correctly")
def test_small_percentage_calculation(self, mock_model_cache):
"""
What it does: Calculates tokens for small percentage.
Goal: Verify precision for small percentages.
"""
print("Setup: Context usage 0.5%...")
context_usage_percentage = 0.5
completion_tokens = 50
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
# 0.5% of 200000 = 1000 total tokens
# prompt_tokens = 1000 - 50 = 950
print(f"Comparing total_tokens: Expected 1000, Got {total_tokens}")
assert total_tokens == 1000
print(f"Comparing prompt_tokens: Expected 950, Got {prompt_tokens}")
assert prompt_tokens == 950
print("✓ Small percentage calculated correctly")
def test_large_percentage_calculation(self, mock_model_cache):
"""
What it does: Calculates tokens for large percentage.
Goal: Verify calculation for high context usage.
"""
print("Setup: Context usage 95%...")
context_usage_percentage = 95.0
completion_tokens = 1000
print("Action: Calculating tokens...")
prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage(
context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4"
)
# 95% of 200000 = 190000 total tokens
# prompt_tokens = 190000 - 1000 = 189000
print(f"Comparing total_tokens: Expected 190000, Got {total_tokens}")
assert total_tokens == 190000
print(f"Comparing prompt_tokens: Expected 189000, Got {prompt_tokens}")
assert prompt_tokens == 189000
print("✓ Large percentage calculated correctly")
# ==================================================================================================
# Tests for thinking parser integration
# ==================================================================================================
class TestThinkingParserIntegration:
"""Tests for thinking parser integration in streaming."""
@pytest.mark.asyncio
async def test_thinking_parser_enabled_when_fake_reasoning_on(self, mock_response, mock_parser):
"""
What it does: Enables thinking parser when FAKE_REASONING_ENABLED is True.
Goal: Verify thinking parser is created.
"""
print("Setup: Enable fake reasoning...")
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream with fake reasoning enabled...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', True):
with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class:
mock_thinking_parser = MagicMock()
mock_thinking_parser.feed.return_value = MagicMock(
thinking_content=None,
regular_content="Hello",
is_first_thinking_chunk=False,
is_last_thinking_chunk=False
)
mock_thinking_parser.finalize.return_value = MagicMock(
thinking_content=None,
regular_content=None,
is_first_thinking_chunk=False,
is_last_thinking_chunk=False
)
mock_thinking_parser.found_thinking_block = False
mock_thinking_parser_class.return_value = mock_thinking_parser
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
# Verify ThinkingParser was instantiated
mock_thinking_parser_class.assert_called_once()
print("✓ Thinking parser enabled when fake reasoning is on")
@pytest.mark.asyncio
async def test_thinking_parser_disabled_when_fake_reasoning_off(self, mock_response, mock_parser):
"""
What it does: Disables thinking parser when FAKE_REASONING_ENABLED is False.
Goal: Verify thinking parser is not created.
"""
print("Setup: Disable fake reasoning...")
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream with fake reasoning disabled...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class:
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
events.append(event)
# Verify ThinkingParser was NOT instantiated
mock_thinking_parser_class.assert_not_called()
print("✓ Thinking parser disabled when fake reasoning is off")
@pytest.mark.asyncio
async def test_thinking_parser_can_be_disabled_via_parameter(self, mock_response, mock_parser):
"""
What it does: Disables thinking parser via enable_thinking_parser parameter.
Goal: Verify parameter overrides config.
"""
print("Setup: Enable fake reasoning but disable via parameter...")
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
mock_parser.get_tool_calls.return_value = []
async def mock_aiter_bytes():
yield b'chunk1'
mock_response.aiter_bytes = mock_aiter_bytes
print("Action: Parsing stream with thinking parser disabled via parameter...")
events = []
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', True):
with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class:
async for event in parse_kiro_stream(
mock_response,
first_token_timeout=30,
enable_thinking_parser=False
):
events.append(event)
# Verify ThinkingParser was NOT instantiated
mock_thinking_parser_class.assert_not_called()
print("✓ Thinking parser disabled via parameter")
# ==================================================================================================
# Tests for error handling
# ==================================================================================================
class TestStreamingCoreErrorHandling:
"""Tests for error handling in streaming_core."""
@pytest.mark.asyncio
async def test_propagates_first_token_timeout_error(self, mock_response):
"""
What it does: Propagates FirstTokenTimeoutError.
Goal: Verify timeout error is not caught internally.
"""
print("Setup: Mock response that times out...")
async def mock_aiter_bytes():
yield b'chunk'
mock_response.aiter_bytes = mock_aiter_bytes
async def mock_wait_for_timeout(*args, **kwargs):
raise asyncio.TimeoutError()
print("Action: Parsing stream with timeout...")
with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_timeout):
with pytest.raises(FirstTokenTimeoutError):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
pass
print("✓ FirstTokenTimeoutError propagated correctly")
@pytest.mark.asyncio
async def test_propagates_generator_exit(self, mock_response, mock_parser):
"""
What it does: Propagates GeneratorExit.
Goal: Verify client disconnect is handled.
"""
print("Setup: Mock response that raises GeneratorExit...")
async def mock_aiter_bytes():
yield b'chunk1'
raise GeneratorExit()
mock_response.aiter_bytes = mock_aiter_bytes
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
print("Action: Parsing stream with GeneratorExit...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with pytest.raises(GeneratorExit):
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
pass
print("✓ GeneratorExit propagated correctly")
@pytest.mark.asyncio
async def test_propagates_other_exceptions(self, mock_response, mock_parser):
"""
What it does: Propagates other exceptions.
Goal: Verify errors are not swallowed.
"""
print("Setup: Mock response that raises RuntimeError...")
async def mock_aiter_bytes():
yield b'chunk1'
raise RuntimeError("Test error")
mock_response.aiter_bytes = mock_aiter_bytes
mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}]
print("Action: Parsing stream with RuntimeError...")
with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser):
with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False):
with pytest.raises(RuntimeError) as exc_info:
async for event in parse_kiro_stream(mock_response, first_token_timeout=30):
pass
print(f"Caught exception: {exc_info.value}")
assert "Test error" in str(exc_info.value)
print("✓ RuntimeError propagated correctly")
# ==================================================================================================
# Tests for stream_with_first_token_retry()
# ==================================================================================================
class TestStreamWithFirstTokenRetryCore:
"""
Tests for stream_with_first_token_retry() generic function.
This function provides automatic retry logic on first token timeout.
It is used by both OpenAI and Anthropic streaming implementations.
"""
@pytest.mark.asyncio
async def test_yields_chunks_on_success(self):
"""
What it does: Yields chunks on successful streaming.
Goal: Verify normal operation without retries.
"""
print("Setup: Mock successful request...")
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aclose = AsyncMock()
async def mock_make_request():
return mock_response
async def mock_stream_processor(response):
yield "chunk1"
yield "chunk2"
yield "chunk3"
print("Action: Streaming with retry wrapper...")
chunks = []
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
assert len(chunks) == 3
assert chunks == ["chunk1", "chunk2", "chunk3"]
print("✓ Chunks yielded on success")
@pytest.mark.asyncio
async def test_retries_on_first_token_timeout(self):
"""
What it does: Retries on first token timeout.
Goal: Verify retry logic is triggered.
"""
print("Setup: Mock request that times out then succeeds...")
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
nonlocal call_count
if call_count == 1:
raise FirstTokenTimeoutError("Timeout on first attempt")
yield "success_chunk"
print("Action: Streaming with retry on timeout...")
chunks = []
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
chunks.append(chunk)
print(f"Call count: {call_count}")
print(f"Received {len(chunks)} chunks")
assert call_count == 2 # First timeout, second success
assert len(chunks) == 1
assert chunks[0] == "success_chunk"
print("✓ Retry on timeout works correctly")
@pytest.mark.asyncio
async def test_raises_exception_after_all_retries(self):
"""
What it does: Raises exception after all retries exhausted.
Goal: Verify error handling when all retries fail.
"""
print("Setup: Mock request that always times out...")
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
print("Action: Streaming with all retries failing...")
with pytest.raises(Exception) as exc_info:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
pass
print(f"Call count: {call_count}")
print(f"Exception: {exc_info.value}")
assert call_count == 3 # Should try exactly 3 times
assert "30" in str(exc_info.value) # Timeout value in message
assert "3" in str(exc_info.value) # Retry count in message
print("✓ Exception raised after all retries")
@pytest.mark.asyncio
async def test_uses_custom_error_callbacks(self):
"""
What it does: Uses custom error callbacks.
Goal: Verify on_http_error and on_all_retries_failed callbacks.
"""
print("Setup: Mock request that always times out with custom callbacks...")
async def mock_make_request():
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
def custom_all_retries_failed(max_retries, timeout):
return ValueError(f"Custom error: {max_retries} retries, {timeout}s timeout")
print("Action: Streaming with custom callback...")
with pytest.raises(ValueError) as exc_info:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=2,
first_token_timeout=15,
on_all_retries_failed=custom_all_retries_failed
):
pass
print(f"Exception: {exc_info.value}")
assert "Custom error" in str(exc_info.value)
assert "2 retries" in str(exc_info.value)
assert "15" in str(exc_info.value)
print("✓ Custom callback used correctly")
@pytest.mark.asyncio
async def test_handles_http_error(self):
"""
What it does: Handles HTTP error from API.
Goal: Verify HTTP errors are handled correctly.
"""
print("Setup: Mock request that returns HTTP error...")
async def mock_make_request():
response = AsyncMock()
response.status_code = 500
response.aread = AsyncMock(return_value=b"Internal Server Error")
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
yield "should not reach"
print("Action: Streaming with HTTP error...")
with pytest.raises(Exception) as exc_info:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
pass
print(f"Exception: {exc_info.value}")
assert "500" in str(exc_info.value)
assert "Internal Server Error" in str(exc_info.value)
print("✓ HTTP error handled correctly")
@pytest.mark.asyncio
async def test_uses_custom_http_error_callback(self):
"""
What it does: Uses custom HTTP error callback.
Goal: Verify on_http_error callback is used.
"""
print("Setup: Mock request with custom HTTP error callback...")
async def mock_make_request():
response = AsyncMock()
response.status_code = 429
response.aread = AsyncMock(return_value=b"Rate limited")
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
yield "should not reach"
def custom_http_error(status_code, error_text):
return RuntimeError(f"Custom HTTP error: {status_code} - {error_text}")
print("Action: Streaming with custom HTTP error callback...")
with pytest.raises(RuntimeError) as exc_info:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30,
on_http_error=custom_http_error
):
pass
print(f"Exception: {exc_info.value}")
assert "Custom HTTP error" in str(exc_info.value)
assert "429" in str(exc_info.value)
assert "Rate limited" in str(exc_info.value)
print("✓ Custom HTTP error callback used correctly")
@pytest.mark.asyncio
async def test_closes_response_on_timeout(self):
"""
What it does: Closes response on timeout.
Goal: Verify response is properly closed after timeout.
"""
print("Setup: Mock request that times out...")
responses = []
async def mock_make_request():
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
responses.append(response)
return response
async def mock_stream_processor(response):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
print("Action: Streaming with timeout...")
try:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=2,
first_token_timeout=30
):
pass
except Exception:
pass
print(f"Created {len(responses)} responses")
# All responses should have been closed
for i, response in enumerate(responses):
print(f"Response {i} aclose called: {response.aclose.called}")
response.aclose.assert_called()
print("✓ Responses closed on timeout")
@pytest.mark.asyncio
async def test_propagates_non_timeout_exceptions(self):
"""
What it does: Propagates non-timeout exceptions without retry.
Goal: Verify other exceptions are not retried.
"""
print("Setup: Mock request that raises RuntimeError...")
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
raise RuntimeError("Not a timeout error")
yield # Make it a generator
print("Action: Streaming with non-timeout error...")
with pytest.raises(RuntimeError) as exc_info:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
pass
print(f"Call count: {call_count}")
print(f"Exception: {exc_info.value}")
assert call_count == 1 # Should NOT retry
assert "Not a timeout error" in str(exc_info.value)
print("✓ Non-timeout exceptions propagated without retry")
@pytest.mark.asyncio
async def test_uses_configured_max_retries(self):
"""
What it does: Uses configured max_retries value.
Goal: Verify max_retries parameter is respected.
"""
print("Setup: Mock request that always times out...")
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
print("Action: Streaming with max_retries=5...")
try:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=5,
first_token_timeout=30
):
pass
except Exception:
pass
print(f"Call count: {call_count}")
assert call_count == 5 # Should try exactly 5 times
print("✓ max_retries parameter respected")
@pytest.mark.asyncio
async def test_multiple_retries_then_success(self):
"""
What it does: Succeeds after multiple retries.
Goal: Verify recovery after multiple failures.
"""
print("Setup: Mock request that fails twice then succeeds...")
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
async def mock_stream_processor(response):
nonlocal call_count
if call_count < 3:
raise FirstTokenTimeoutError(f"Timeout on attempt {call_count}")
yield "finally_success"
print("Action: Streaming with multiple retries...")
chunks = []
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=5,
first_token_timeout=30
):
chunks.append(chunk)
print(f"Call count: {call_count}")
print(f"Received {len(chunks)} chunks")
assert call_count == 3 # Failed twice, succeeded on third
assert len(chunks) == 1
assert chunks[0] == "finally_success"
print("✓ Multiple retries then success works correctly")
@pytest.mark.asyncio
async def test_closes_response_on_http_error(self):
"""
What it does: Closes response on HTTP error.
Goal: Verify response is properly closed after HTTP error.
"""
print("Setup: Mock request that returns HTTP error...")
response = AsyncMock()
response.status_code = 503
response.aread = AsyncMock(return_value=b"Service Unavailable")
response.aclose = AsyncMock()
async def mock_make_request():
return response
async def mock_stream_processor(resp):
yield "should not reach"
print("Action: Streaming with HTTP error...")
try:
async for chunk in stream_with_first_token_retry(
make_request=mock_make_request,
stream_processor=mock_stream_processor,
max_retries=3,
first_token_timeout=30
):
pass
except Exception:
pass
print(f"Response aclose called: {response.aclose.called}")
response.aclose.assert_called()
print("✓ Response closed on HTTP error")