mirror of https://github.com/aliasrobotics/cai.git
1087 lines
38 KiB
Python
1087 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test tool visualization functionality.
|
|
Tests token display, panel creation, streaming panels, and cost tracking.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import pytest
|
|
|
|
# Add src to path
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
|
|
|
|
|
class TestTokenInfoDisplay:
|
|
"""Test _create_token_info_display function."""
|
|
|
|
def test_returns_none_for_empty_token_info(self):
|
|
"""Should return None when token_info is empty or None."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
assert _create_token_info_display(None) is None
|
|
assert _create_token_info_display({}) is None
|
|
|
|
def test_returns_none_for_zero_tokens(self):
|
|
"""Should return None when all token values are zero."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 0,
|
|
"interaction_output_tokens": 0,
|
|
"total_input_tokens": 0,
|
|
"total_output_tokens": 0,
|
|
}
|
|
assert _create_token_info_display(token_info) is None
|
|
|
|
def test_returns_display_for_interaction_tokens(self):
|
|
"""Should return display when interaction tokens are non-zero."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100,
|
|
"interaction_output_tokens": 50,
|
|
"total_input_tokens": 0,
|
|
"total_output_tokens": 0,
|
|
}
|
|
result = _create_token_info_display(token_info)
|
|
assert result is not None
|
|
|
|
def test_returns_display_for_total_tokens(self):
|
|
"""Should return display when total tokens are non-zero."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 0,
|
|
"interaction_output_tokens": 0,
|
|
"total_input_tokens": 100,
|
|
"total_output_tokens": 50,
|
|
}
|
|
result = _create_token_info_display(token_info)
|
|
assert result is not None
|
|
|
|
def test_includes_cache_tokens_when_present(self):
|
|
"""Should include cache read/write tokens in display."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100,
|
|
"interaction_output_tokens": 50,
|
|
"cache_read_tokens": 80,
|
|
"cache_creation_tokens": 20,
|
|
}
|
|
result = _create_token_info_display(token_info)
|
|
assert result is not None
|
|
# Result is a Rich Text object, check it was created
|
|
from rich.text import Text
|
|
assert isinstance(result, Text)
|
|
|
|
|
|
class TestToolPanelContent:
|
|
"""Test _create_tool_panel_content function."""
|
|
|
|
def test_creates_panel_with_basic_args(self):
|
|
"""Should create panel content with basic arguments."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="test_tool",
|
|
args={"arg1": "value1"},
|
|
output="test output",
|
|
execution_info=None,
|
|
token_info=None,
|
|
)
|
|
assert header is not None
|
|
assert content is not None
|
|
|
|
def test_creates_panel_with_string_args(self):
|
|
"""Should create panel content with string arguments."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="generic_linux_command",
|
|
args="ls -la",
|
|
output="total 0\ndrwxr-xr-x 2 user user 40 Jan 1 00:00 .",
|
|
execution_info={"status": "completed"},
|
|
token_info=None,
|
|
)
|
|
assert header is not None
|
|
assert content is not None
|
|
|
|
def test_includes_token_info_when_provided(self):
|
|
"""Should include token info in panel when provided with non-zero values."""
|
|
from cai.util import _create_tool_panel_content
|
|
from rich.console import Group
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100,
|
|
"interaction_output_tokens": 50,
|
|
"model": "test-model",
|
|
}
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="test_tool",
|
|
args={"arg": "value"},
|
|
output="test output",
|
|
execution_info={"status": "completed"},
|
|
token_info=token_info,
|
|
)
|
|
assert content is not None
|
|
assert isinstance(content, Group)
|
|
|
|
def test_excludes_token_info_for_zero_values(self):
|
|
"""Should exclude token info from panel when all values are zero."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 0,
|
|
"interaction_output_tokens": 0,
|
|
"total_input_tokens": 0,
|
|
"total_output_tokens": 0,
|
|
}
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="test_tool",
|
|
args={"arg": "value"},
|
|
output="test output",
|
|
execution_info={"status": "completed"},
|
|
token_info=token_info,
|
|
)
|
|
# Content should still be created, just without token section
|
|
assert content is not None
|
|
|
|
def test_handles_execute_code_tool(self):
|
|
"""Should handle execute_code tool with special formatting."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="execute_code",
|
|
args={
|
|
"command": "execute",
|
|
"code": "print('hello')",
|
|
"language": "python",
|
|
},
|
|
output="hello",
|
|
execution_info={"status": "completed"},
|
|
token_info=None,
|
|
)
|
|
assert header is not None
|
|
assert content is not None
|
|
|
|
|
|
class TestEnrichTokenInfoForPricing:
|
|
"""Test enrich_token_info_for_pricing function."""
|
|
|
|
def test_preserves_existing_values(self):
|
|
"""Should preserve existing token values."""
|
|
from cai.util import enrich_token_info_for_pricing
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100,
|
|
"interaction_output_tokens": 50,
|
|
"model": "test-model",
|
|
}
|
|
enriched = enrich_token_info_for_pricing(token_info)
|
|
assert enriched["interaction_input_tokens"] == 100
|
|
assert enriched["interaction_output_tokens"] == 50
|
|
|
|
def test_adds_missing_fields(self):
|
|
"""Should add missing fields with default values."""
|
|
from cai.util import enrich_token_info_for_pricing
|
|
|
|
token_info = {"model": "test-model"}
|
|
enriched = enrich_token_info_for_pricing(token_info)
|
|
assert "interaction_input_tokens" in enriched
|
|
assert "interaction_output_tokens" in enriched
|
|
assert "total_input_tokens" in enriched
|
|
assert "total_output_tokens" in enriched
|
|
|
|
def test_handles_cache_tokens(self):
|
|
"""Should handle cache read and write tokens."""
|
|
from cai.util import enrich_token_info_for_pricing
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100,
|
|
"cache_read_tokens": 80,
|
|
"cache_creation_tokens": 20,
|
|
"model": "test-model",
|
|
}
|
|
enriched = enrich_token_info_for_pricing(token_info)
|
|
assert enriched.get("cache_read_tokens") == 80
|
|
assert enriched.get("cache_creation_tokens") == 20
|
|
|
|
|
|
class TestCostTracker:
|
|
"""Test CostTracker class functionality."""
|
|
|
|
def test_cost_tracker_has_required_attributes(self):
|
|
"""Should have all required token tracking attributes."""
|
|
from cai.util import COST_TRACKER
|
|
|
|
# Check that COST_TRACKER exists and has basic attributes
|
|
assert hasattr(COST_TRACKER, "current_agent_input_tokens")
|
|
assert hasattr(COST_TRACKER, "current_agent_output_tokens")
|
|
|
|
def test_cost_tracker_has_cache_token_attributes_initialized(self):
|
|
"""CostTracker should have cache token attributes initialized in __init__."""
|
|
from cai.util import CostTracker
|
|
|
|
# Create a fresh instance to verify initialization
|
|
fresh_tracker = CostTracker()
|
|
|
|
# Verify cache tokens are initialized to 0
|
|
assert hasattr(fresh_tracker, "cache_read_tokens")
|
|
assert hasattr(fresh_tracker, "cache_creation_tokens")
|
|
assert fresh_tracker.cache_read_tokens == 0
|
|
assert fresh_tracker.cache_creation_tokens == 0
|
|
|
|
def test_cost_tracker_can_store_interaction_tokens(self):
|
|
"""Should be able to store interaction token values."""
|
|
from cai.util import COST_TRACKER
|
|
|
|
# Set values
|
|
COST_TRACKER.interaction_input_tokens = 100
|
|
COST_TRACKER.interaction_output_tokens = 50
|
|
|
|
# Verify they're stored
|
|
assert COST_TRACKER.interaction_input_tokens == 100
|
|
assert COST_TRACKER.interaction_output_tokens == 50
|
|
|
|
# Clean up
|
|
COST_TRACKER.interaction_input_tokens = 0
|
|
COST_TRACKER.interaction_output_tokens = 0
|
|
|
|
def test_cost_tracker_can_store_cache_tokens(self):
|
|
"""Should be able to store cache token values."""
|
|
from cai.util import COST_TRACKER
|
|
|
|
# Set values
|
|
COST_TRACKER.cache_read_tokens = 80
|
|
COST_TRACKER.cache_creation_tokens = 20
|
|
|
|
# Verify they're stored
|
|
assert COST_TRACKER.cache_read_tokens == 80
|
|
assert COST_TRACKER.cache_creation_tokens == 20
|
|
|
|
# Clean up
|
|
COST_TRACKER.cache_read_tokens = 0
|
|
COST_TRACKER.cache_creation_tokens = 0
|
|
|
|
|
|
class TestTokenDisplay:
|
|
"""Test _create_token_display function."""
|
|
|
|
def test_creates_display_with_basic_values(self):
|
|
"""Should create token display with basic values."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="test-model",
|
|
)
|
|
assert result is not None
|
|
from rich.text import Text
|
|
assert isinstance(result, Text)
|
|
|
|
def test_includes_cache_tokens_in_display(self):
|
|
"""Should include cache tokens when provided."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="test-model",
|
|
cache_read_tokens=80,
|
|
cache_creation_tokens=20,
|
|
)
|
|
assert result is not None
|
|
# The result should contain cache info
|
|
result_str = str(result)
|
|
# Cache tokens should be included when non-zero
|
|
assert "CR" in result_str or "80" in result_str or result is not None
|
|
|
|
def test_includes_cost_information(self):
|
|
"""Should include cost information when provided."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="test-model",
|
|
interaction_cost=0.001,
|
|
total_cost=0.002,
|
|
)
|
|
assert result is not None
|
|
|
|
|
|
class TestStreamingPanelGrouping:
|
|
"""Test streaming panel grouping functionality."""
|
|
|
|
def test_grouped_streaming_tools_dict_exists(self):
|
|
"""Should have _GROUPED_STREAMING_TOOLS dictionary."""
|
|
from cai.util import _GROUPED_STREAMING_TOOLS
|
|
assert isinstance(_GROUPED_STREAMING_TOOLS, dict)
|
|
|
|
def test_live_streaming_panels_dict_exists(self):
|
|
"""Should have _LIVE_STREAMING_PANELS dictionary."""
|
|
from cai.util import _LIVE_STREAMING_PANELS
|
|
assert isinstance(_LIVE_STREAMING_PANELS, dict)
|
|
|
|
|
|
class TestModelCostTrackerIntegration:
|
|
"""Test integration between model and COST_TRACKER."""
|
|
|
|
def test_chatcompletions_model_has_token_tracking_in_init(self):
|
|
"""OpenAIChatCompletionsModel __init__ should set up token tracking attributes."""
|
|
# This test verifies the model initializes token tracking attributes
|
|
# by checking the __init__ method signature and the model's behavior
|
|
from cai.sdk.agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
|
import inspect
|
|
|
|
# Get the __init__ method source to verify it sets up token tracking
|
|
init_source = inspect.getsource(OpenAIChatCompletionsModel.__init__)
|
|
|
|
# Verify the init sets up the interaction token attributes
|
|
assert "interaction_input_tokens" in init_source
|
|
assert "interaction_output_tokens" in init_source
|
|
assert "cache_read_tokens" in init_source
|
|
assert "cache_creation_tokens" in init_source
|
|
|
|
|
|
class TestTimingInfo:
|
|
"""Test timing information functions."""
|
|
|
|
def test_get_timing_info_returns_list(self):
|
|
"""Should return a list of timing info strings."""
|
|
from cai.util import _get_timing_info
|
|
|
|
timing_info, tool_time = _get_timing_info(None)
|
|
assert isinstance(timing_info, list)
|
|
|
|
def test_get_timing_info_with_execution_info(self):
|
|
"""Should include tool time when provided in execution_info."""
|
|
from cai.util import _get_timing_info
|
|
|
|
execution_info = {"tool_time": 1.5}
|
|
timing_info, tool_time = _get_timing_info(execution_info)
|
|
assert tool_time == 1.5
|
|
assert any("Tool" in t for t in timing_info)
|
|
|
|
|
|
class TestFormatTime:
|
|
"""Test format_time function."""
|
|
|
|
def test_formats_seconds(self):
|
|
"""Should format seconds correctly."""
|
|
from cai.util import format_time
|
|
|
|
assert format_time(0.5) == "0.5s"
|
|
assert format_time(1.0) == "1.0s"
|
|
assert format_time(59.9) == "59.9s"
|
|
|
|
def test_formats_minutes(self):
|
|
"""Should format minutes correctly."""
|
|
from cai.util import format_time
|
|
|
|
result = format_time(90) # 1.5 minutes
|
|
assert "m" in result or "1" in result
|
|
|
|
def test_handles_none(self):
|
|
"""Should handle None gracefully."""
|
|
from cai.util import format_time
|
|
|
|
# format_time should handle None or return something reasonable
|
|
try:
|
|
result = format_time(None)
|
|
# If it doesn't raise, that's fine
|
|
except (TypeError, ValueError):
|
|
# Expected if it doesn't handle None
|
|
pass
|
|
|
|
|
|
class TestMultiToolCallsNonStreaming:
|
|
"""Test multi-tool call handling in non-streaming mode."""
|
|
|
|
def test_multiple_tool_outputs_no_duplicates(self):
|
|
"""Should not create duplicate panels for multiple tool outputs."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
tool_calls = [
|
|
{"name": "echo", "args": "AAA", "output": "AAA"},
|
|
{"name": "echo", "args": "BBB", "output": "BBB"},
|
|
{"name": "echo", "args": "CCC", "output": "CCC"},
|
|
]
|
|
|
|
panels = []
|
|
for tc in tool_calls:
|
|
header, content = _create_tool_panel_content(
|
|
tool_name=tc["name"],
|
|
args=tc["args"],
|
|
output=tc["output"],
|
|
execution_info={"status": "completed"},
|
|
token_info=None,
|
|
)
|
|
panels.append((header, content))
|
|
|
|
# Should have exactly 3 panels, no duplicates
|
|
assert len(panels) == 3
|
|
# Each panel should be unique
|
|
outputs = [tc["output"] for tc in tool_calls]
|
|
assert len(set(outputs)) == 3
|
|
|
|
def test_parallel_tool_calls_distinct_outputs(self):
|
|
"""Parallel tool calls should maintain distinct outputs."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
# Simulate 3 parallel ping commands
|
|
tool_calls = [
|
|
{"name": "ping", "args": "-c 1 google.com", "output": "PING google.com..."},
|
|
{"name": "ping", "args": "-c 1 8.8.8.8", "output": "PING 8.8.8.8..."},
|
|
{"name": "ping", "args": "-c 1 192.168.1.1", "output": "PING 192.168.1.1..."},
|
|
]
|
|
|
|
results = []
|
|
for tc in tool_calls:
|
|
header, content = _create_tool_panel_content(
|
|
tool_name=tc["name"],
|
|
args=tc["args"],
|
|
output=tc["output"],
|
|
execution_info={"status": "completed"},
|
|
token_info={"interaction_input_tokens": 100, "interaction_output_tokens": 50},
|
|
)
|
|
results.append({"header": header, "content": content, "args": tc["args"]})
|
|
|
|
# Verify all 3 results are distinct
|
|
assert len(results) == 3
|
|
args_list = [r["args"] for r in results]
|
|
assert len(set(args_list)) == 3 # All args should be unique
|
|
|
|
|
|
class TestMessageHistoryNoDuplicates:
|
|
"""Test that message history doesn't have duplicates."""
|
|
|
|
def test_fix_message_list_no_duplicate_tool_responses(self):
|
|
"""fix_message_list should not create duplicate tool responses."""
|
|
from cai.util import fix_message_list
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Run 3 commands"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{"id": "call_1", "type": "function", "function": {"name": "echo", "arguments": '{"text": "A"}'}},
|
|
{"id": "call_2", "type": "function", "function": {"name": "echo", "arguments": '{"text": "B"}'}},
|
|
{"id": "call_3", "type": "function", "function": {"name": "echo", "arguments": '{"text": "C"}'}},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "A"},
|
|
{"role": "tool", "tool_call_id": "call_2", "content": "B"},
|
|
{"role": "tool", "tool_call_id": "call_3", "content": "C"},
|
|
]
|
|
|
|
fixed = fix_message_list(messages)
|
|
|
|
# Count tool responses
|
|
tool_responses = [m for m in fixed if m.get("role") == "tool"]
|
|
tool_call_ids = [m.get("tool_call_id") for m in tool_responses]
|
|
|
|
# Should have exactly 3 tool responses, no duplicates
|
|
assert len(tool_responses) == 3
|
|
assert len(set(tool_call_ids)) == 3
|
|
|
|
def test_fix_message_list_adds_missing_tool_response(self):
|
|
"""fix_message_list should add missing tool responses."""
|
|
from cai.util import fix_message_list
|
|
|
|
# Missing tool response for call_2
|
|
messages = [
|
|
{"role": "user", "content": "Run commands"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{"id": "call_1", "type": "function", "function": {"name": "echo", "arguments": '{}'}},
|
|
{"id": "call_2", "type": "function", "function": {"name": "echo", "arguments": '{}'}},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "result1"},
|
|
# Missing call_2 response
|
|
]
|
|
|
|
fixed = fix_message_list(messages)
|
|
|
|
# Should now have 2 tool responses
|
|
tool_responses = [m for m in fixed if m.get("role") == "tool"]
|
|
assert len(tool_responses) == 2
|
|
|
|
def test_no_unknown_function_duplicates(self):
|
|
"""Should not create unknown_function duplicates during cleanup."""
|
|
from cai.util import fix_message_list
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Test"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{"id": "call_real", "type": "function", "function": {"name": "real_function", "arguments": '{}'}},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_real", "content": "result"},
|
|
]
|
|
|
|
fixed = fix_message_list(messages)
|
|
|
|
# Check no unknown_function was added
|
|
for msg in fixed:
|
|
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
for tc in msg["tool_calls"]:
|
|
assert tc.get("function", {}).get("name") != "unknown_function"
|
|
|
|
|
|
class TestLargeMessageHistory:
|
|
"""Test handling of large message histories."""
|
|
|
|
def test_large_message_history_no_corruption(self):
|
|
"""Large message history should not get corrupted."""
|
|
from cai.util import fix_message_list
|
|
|
|
# Create a large message history with 50 tool calls
|
|
messages = [{"role": "user", "content": "Run many commands"}]
|
|
|
|
tool_calls = []
|
|
for i in range(50):
|
|
tool_calls.append({
|
|
"id": f"call_{i}",
|
|
"type": "function",
|
|
"function": {"name": f"command_{i}", "arguments": f'{{"index": {i}}}'},
|
|
})
|
|
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": tool_calls,
|
|
})
|
|
|
|
# Add all tool responses
|
|
for i in range(50):
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": f"call_{i}",
|
|
"content": f"result_{i}",
|
|
})
|
|
|
|
fixed = fix_message_list(messages)
|
|
|
|
# Verify integrity
|
|
tool_responses = [m for m in fixed if m.get("role") == "tool"]
|
|
assert len(tool_responses) == 50
|
|
|
|
# Verify no duplicates
|
|
tool_call_ids = [m.get("tool_call_id") for m in tool_responses]
|
|
assert len(set(tool_call_ids)) == 50
|
|
|
|
def test_alternating_user_assistant_tool_pattern(self):
|
|
"""Should handle alternating conversation patterns correctly."""
|
|
from cai.util import fix_message_list
|
|
|
|
messages = []
|
|
for i in range(10):
|
|
# User turn
|
|
messages.append({"role": "user", "content": f"Command {i}"})
|
|
# Assistant with tool call
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [{
|
|
"id": f"call_{i}",
|
|
"type": "function",
|
|
"function": {"name": "echo", "arguments": f'{{"msg": "{i}"}}'},
|
|
}],
|
|
})
|
|
# Tool response
|
|
messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": f"call_{i}",
|
|
"content": f"Result {i}",
|
|
})
|
|
# Assistant response
|
|
messages.append({
|
|
"role": "assistant",
|
|
"content": f"Done with command {i}",
|
|
})
|
|
|
|
fixed = fix_message_list(messages)
|
|
|
|
# Should maintain the same structure
|
|
assert len(fixed) == len(messages)
|
|
|
|
# Verify pattern integrity
|
|
user_msgs = [m for m in fixed if m.get("role") == "user"]
|
|
tool_msgs = [m for m in fixed if m.get("role") == "tool"]
|
|
assert len(user_msgs) == 10
|
|
assert len(tool_msgs) == 10
|
|
|
|
|
|
class TestStreamingPanelFinalization:
|
|
"""Test streaming panel finalization logic."""
|
|
|
|
def test_single_tool_uses_single_panel_format(self):
|
|
"""Single tool in streaming should use single panel format."""
|
|
from cai.util import _GROUPED_STREAMING_TOOLS, _GROUPED_TOOLS_LOCK
|
|
|
|
# This tests the logic conceptually - actual streaming requires async context
|
|
# The key assertion is that _finalize_tool_group handles tool_count == 1 specially
|
|
|
|
# Verify the structures exist and are properly initialized
|
|
assert isinstance(_GROUPED_STREAMING_TOOLS, dict)
|
|
|
|
def test_multi_tool_uses_grouped_format(self):
|
|
"""Multiple tools in streaming should use grouped format."""
|
|
from cai.util import _GROUPED_STREAMING_TOOLS
|
|
|
|
# Conceptual test - actual behavior requires async streaming context
|
|
assert isinstance(_GROUPED_STREAMING_TOOLS, dict)
|
|
|
|
|
|
class TestTokenInfoEdgeCases:
|
|
"""Test edge cases in token info handling."""
|
|
|
|
def test_negative_token_values_handled(self):
|
|
"""Should handle negative token values gracefully."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
# Negative values should be treated as invalid/zero
|
|
token_info = {
|
|
"interaction_input_tokens": -1,
|
|
"interaction_output_tokens": -1,
|
|
}
|
|
result = _create_token_info_display(token_info)
|
|
# Should return None since negative is not > 0
|
|
assert result is None
|
|
|
|
def test_very_large_token_values(self):
|
|
"""Should handle very large token values."""
|
|
from cai.util import _create_token_info_display
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 1000000,
|
|
"interaction_output_tokens": 500000,
|
|
"total_input_tokens": 10000000,
|
|
"total_output_tokens": 5000000,
|
|
}
|
|
result = _create_token_info_display(token_info)
|
|
assert result is not None
|
|
|
|
def test_float_token_values(self):
|
|
"""Should handle float token values by converting to int."""
|
|
from cai.util import enrich_token_info_for_pricing
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": 100.5,
|
|
"interaction_output_tokens": 50.7,
|
|
}
|
|
enriched = enrich_token_info_for_pricing(token_info)
|
|
# Values should be preserved (the display function handles conversion)
|
|
assert enriched["interaction_input_tokens"] == 100.5
|
|
|
|
def test_string_token_values_in_enrichment(self):
|
|
"""Should handle string token values gracefully."""
|
|
from cai.util import enrich_token_info_for_pricing
|
|
|
|
token_info = {
|
|
"interaction_input_tokens": "100",
|
|
"interaction_output_tokens": "50",
|
|
}
|
|
# Should not crash
|
|
enriched = enrich_token_info_for_pricing(token_info)
|
|
assert enriched is not None
|
|
|
|
|
|
class TestCacheTokenDisplay:
|
|
"""Test cache token (CR/CW) display functionality."""
|
|
|
|
def test_cache_read_tokens_displayed(self):
|
|
"""Cache read tokens should be displayed when present."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="claude-sonnet",
|
|
cache_read_tokens=80,
|
|
cache_creation_tokens=0,
|
|
)
|
|
result_str = str(result)
|
|
# Should contain CR indicator
|
|
assert "CR" in result_str or "80" in result_str
|
|
|
|
def test_cache_write_tokens_displayed(self):
|
|
"""Cache write/creation tokens should be displayed when present."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="claude-sonnet",
|
|
cache_read_tokens=0,
|
|
cache_creation_tokens=20,
|
|
)
|
|
result_str = str(result)
|
|
# Should contain CW indicator
|
|
assert "CW" in result_str or "20" in result_str
|
|
|
|
def test_both_cache_tokens_displayed(self):
|
|
"""Both CR and CW should be displayed when both present."""
|
|
from cai.util import _create_token_display
|
|
|
|
result = _create_token_display(
|
|
interaction_input_tokens=100,
|
|
interaction_output_tokens=50,
|
|
interaction_reasoning_tokens=0,
|
|
total_input_tokens=100,
|
|
total_output_tokens=50,
|
|
total_reasoning_tokens=0,
|
|
model="claude-sonnet",
|
|
cache_read_tokens=80,
|
|
cache_creation_tokens=20,
|
|
)
|
|
result_str = str(result)
|
|
# Both should be present
|
|
assert ("CR" in result_str or "80" in result_str)
|
|
assert ("CW" in result_str or "20" in result_str)
|
|
|
|
|
|
class TestOutputDeduplication:
|
|
"""Test output deduplication in cli_print_tool_output."""
|
|
|
|
def test_output_hash_tracking_exists(self):
|
|
"""cli_print_tool_output uses _output_hashes for deduplication (created lazily)."""
|
|
from cai.util import cli_print_tool_output
|
|
import inspect
|
|
|
|
# Verify the function's source code references _output_hashes for deduplication
|
|
source = inspect.getsource(cli_print_tool_output)
|
|
assert "_output_hashes" in source, "Function should use _output_hashes for deduplication"
|
|
|
|
def test_streaming_sessions_tracking_available(self):
|
|
"""cli_print_tool_output uses _streaming_sessions (created lazily when needed)."""
|
|
from cai.util import cli_print_tool_output
|
|
import inspect
|
|
|
|
# Verify the function's source code references _streaming_sessions
|
|
source = inspect.getsource(cli_print_tool_output)
|
|
assert "_streaming_sessions" in source, "Function should use _streaming_sessions"
|
|
|
|
|
|
class TestPanelCreationConsistency:
|
|
"""Test consistency of panel creation across modes."""
|
|
|
|
def test_completed_panel_has_green_border(self):
|
|
"""Completed panels should have green border style."""
|
|
from cai.util import _create_tool_panel_content
|
|
from rich.panel import Panel
|
|
from rich.box import ROUNDED
|
|
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="test",
|
|
args={},
|
|
output="done",
|
|
execution_info={"status": "completed"},
|
|
token_info=None,
|
|
)
|
|
|
|
# Create the panel as the code does
|
|
panel = Panel(
|
|
content,
|
|
title="[bold green]Completed[/bold green]",
|
|
border_style="green",
|
|
padding=(0, 1),
|
|
box=ROUNDED,
|
|
title_align="left",
|
|
)
|
|
|
|
assert panel.border_style == "green"
|
|
|
|
def test_error_panel_styling(self):
|
|
"""Error panels should have appropriate styling."""
|
|
from cai.util import _create_tool_panel_content
|
|
|
|
header, content = _create_tool_panel_content(
|
|
tool_name="test",
|
|
args={},
|
|
output="Error: command failed",
|
|
execution_info={"status": "error"},
|
|
token_info=None,
|
|
)
|
|
|
|
# Content should be created regardless of status
|
|
assert content is not None
|
|
|
|
|
|
class TestCtrlCInterruptHandling:
|
|
"""Test CTRL+C interrupt handling in streaming mode."""
|
|
|
|
def test_cleanup_all_streaming_resources_exists(self):
|
|
"""cleanup_all_streaming_resources should be importable and callable."""
|
|
from cai.util import cleanup_all_streaming_resources
|
|
|
|
# Should be callable without error
|
|
assert callable(cleanup_all_streaming_resources)
|
|
|
|
def test_force_stop_all_panels_exists(self):
|
|
"""_force_stop_all_panels should be importable and callable."""
|
|
from cai.util import _force_stop_all_panels
|
|
|
|
assert callable(_force_stop_all_panels)
|
|
|
|
def test_cleanup_handles_empty_panels(self):
|
|
"""Cleanup should handle case where no panels exist."""
|
|
from cai.util import cleanup_all_streaming_resources, _LIVE_STREAMING_PANELS
|
|
|
|
# Clear any existing panels
|
|
_LIVE_STREAMING_PANELS.clear()
|
|
|
|
# Should not raise
|
|
cleanup_all_streaming_resources()
|
|
|
|
def test_cleanup_handles_grouped_streaming_tools(self):
|
|
"""Cleanup should handle grouped streaming tools."""
|
|
from cai.util import (
|
|
cleanup_all_streaming_resources,
|
|
_GROUPED_STREAMING_TOOLS,
|
|
_LIVE_STREAMING_PANELS,
|
|
)
|
|
|
|
# Clear panels first
|
|
_LIVE_STREAMING_PANELS.clear()
|
|
_GROUPED_STREAMING_TOOLS.clear()
|
|
|
|
# Add a mock group
|
|
_GROUPED_STREAMING_TOOLS["test_group"] = {
|
|
"call_ids": ["call1", "call2"],
|
|
"tools": {},
|
|
"live_panel": None, # No actual Live panel
|
|
}
|
|
|
|
# Should not raise
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Group should be cleaned up
|
|
assert "test_group" not in _GROUPED_STREAMING_TOOLS
|
|
|
|
def test_cleanup_stops_live_panel_with_stop_method(self):
|
|
"""Cleanup should call stop() on Live panels."""
|
|
from cai.util import cleanup_all_streaming_resources, _LIVE_STREAMING_PANELS
|
|
|
|
# Create a mock Live panel
|
|
class MockLivePanel:
|
|
def __init__(self):
|
|
self.stopped = False
|
|
|
|
def stop(self):
|
|
self.stopped = True
|
|
|
|
mock_panel = MockLivePanel()
|
|
_LIVE_STREAMING_PANELS["test_call_id"] = mock_panel
|
|
|
|
# Run cleanup
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Panel should have been stopped
|
|
assert mock_panel.stopped
|
|
|
|
def test_cleanup_handles_dict_panels(self):
|
|
"""Cleanup should handle dict-type static panels (no stop method)."""
|
|
from cai.util import cleanup_all_streaming_resources, _LIVE_STREAMING_PANELS
|
|
|
|
# Add a static dict panel (has no stop() method)
|
|
_LIVE_STREAMING_PANELS["test_static"] = {
|
|
"type": "static",
|
|
"displayed": True,
|
|
"last_update": 123.456,
|
|
}
|
|
|
|
# Should not raise
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Panel should be cleaned up
|
|
assert "test_static" not in _LIVE_STREAMING_PANELS
|
|
|
|
def test_force_stop_handles_empty_state(self):
|
|
"""_force_stop_all_panels should handle empty state."""
|
|
from cai.util import (
|
|
_force_stop_all_panels,
|
|
_LIVE_STREAMING_PANELS,
|
|
_GROUPED_STREAMING_TOOLS,
|
|
_CLAUDE_THINKING_PANELS,
|
|
)
|
|
|
|
# Clear all
|
|
_LIVE_STREAMING_PANELS.clear()
|
|
_GROUPED_STREAMING_TOOLS.clear()
|
|
_CLAUDE_THINKING_PANELS.clear()
|
|
|
|
# Should not raise
|
|
_force_stop_all_panels()
|
|
|
|
def test_signal_handler_function_exists(self):
|
|
"""signal_handler should be defined in util module."""
|
|
from cai.util import signal_handler
|
|
|
|
assert callable(signal_handler)
|
|
|
|
def test_interrupt_count_tracking(self):
|
|
"""Interrupt count and time should be tracked as module globals."""
|
|
from cai import util
|
|
|
|
assert hasattr(util, "_interrupt_count")
|
|
assert hasattr(util, "_last_interrupt_time")
|
|
|
|
def test_cleanup_restores_cursor(self):
|
|
"""Cleanup should restore terminal cursor visibility."""
|
|
import inspect
|
|
from cai.util import cleanup_all_streaming_resources
|
|
from cai.util.streaming import restore_terminal_state
|
|
|
|
cleanup_src = inspect.getsource(cleanup_all_streaming_resources)
|
|
assert "restore_terminal_state" in cleanup_src, (
|
|
"cleanup should delegate TTY restore to restore_terminal_state()"
|
|
)
|
|
|
|
restore_src = inspect.getsource(restore_terminal_state)
|
|
assert "show_cursor" in restore_src or "\\033[?25h" in restore_src
|
|
|
|
def test_cleanup_non_blocking_lock(self):
|
|
"""Cleanup should use non-blocking lock acquisition."""
|
|
import inspect
|
|
from cai.util import cleanup_all_streaming_resources
|
|
|
|
source = inspect.getsource(cleanup_all_streaming_resources)
|
|
|
|
# Should use blocking=False for non-blocking lock
|
|
assert "blocking=False" in source
|
|
|
|
def test_cleanup_clears_streaming_sessions(self):
|
|
"""Cleanup should clear streaming sessions."""
|
|
from cai.util import cleanup_all_streaming_resources, cli_print_tool_output
|
|
|
|
# Add a streaming session
|
|
if not hasattr(cli_print_tool_output, "_streaming_sessions"):
|
|
cli_print_tool_output._streaming_sessions = {}
|
|
cli_print_tool_output._streaming_sessions["test_session"] = {"buffer": "test"}
|
|
|
|
# Run cleanup
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Sessions should be cleared
|
|
assert len(cli_print_tool_output._streaming_sessions) == 0
|
|
|
|
def test_grouped_tools_lock_exists(self):
|
|
"""_GROUPED_TOOLS_LOCK should exist for thread safety."""
|
|
from cai.util import _GROUPED_TOOLS_LOCK
|
|
import threading
|
|
|
|
assert isinstance(_GROUPED_TOOLS_LOCK, type(threading.Lock()))
|
|
|
|
def test_panel_update_lock_exists(self):
|
|
"""_PANEL_UPDATE_LOCK should exist for thread safety."""
|
|
from cai.util import _PANEL_UPDATE_LOCK
|
|
import threading
|
|
|
|
assert isinstance(_PANEL_UPDATE_LOCK, type(threading.Lock()))
|
|
|
|
|
|
class TestStreamingPanelCleanupEdgeCases:
|
|
"""Test edge cases in streaming panel cleanup."""
|
|
|
|
def test_cleanup_handles_panel_stop_exception(self):
|
|
"""Cleanup should handle exceptions from panel.stop()."""
|
|
from cai.util import cleanup_all_streaming_resources, _LIVE_STREAMING_PANELS
|
|
|
|
# Create a mock panel that raises on stop
|
|
class BrokenPanel:
|
|
def stop(self):
|
|
raise RuntimeError("Panel broke!")
|
|
|
|
_LIVE_STREAMING_PANELS["broken"] = BrokenPanel()
|
|
|
|
# Should not raise - exception should be caught
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Panel should still be cleaned up
|
|
assert "broken" not in _LIVE_STREAMING_PANELS
|
|
|
|
def test_cleanup_handles_grouped_panel_stop_exception(self):
|
|
"""Cleanup should handle exceptions from grouped panel.stop()."""
|
|
from cai.util import (
|
|
cleanup_all_streaming_resources,
|
|
_GROUPED_STREAMING_TOOLS,
|
|
_LIVE_STREAMING_PANELS,
|
|
)
|
|
|
|
_LIVE_STREAMING_PANELS.clear()
|
|
|
|
# Create a mock grouped panel that raises on stop
|
|
class BrokenGroupedPanel:
|
|
def stop(self):
|
|
raise RuntimeError("Grouped panel broke!")
|
|
|
|
_GROUPED_STREAMING_TOOLS["broken_group"] = {
|
|
"live_panel": BrokenGroupedPanel(),
|
|
"call_ids": [],
|
|
"tools": {},
|
|
}
|
|
|
|
# Should not raise
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Group should be cleaned up
|
|
assert "broken_group" not in _GROUPED_STREAMING_TOOLS
|
|
|
|
def test_force_stop_is_lock_free(self):
|
|
"""_force_stop_all_panels should not acquire any locks."""
|
|
import inspect
|
|
from cai.util import _force_stop_all_panels
|
|
|
|
source = inspect.getsource(_force_stop_all_panels)
|
|
|
|
# Should not use 'with' for lock acquisition or .acquire()
|
|
assert "with _" not in source # No 'with _lock:' patterns
|
|
assert ".acquire(" not in source
|
|
|
|
def test_cleanup_in_progress_flag_reset(self):
|
|
"""_cleanup_in_progress should be reset after cleanup."""
|
|
from cai import util
|
|
from cai.util import cleanup_all_streaming_resources
|
|
|
|
# Run cleanup
|
|
cleanup_all_streaming_resources()
|
|
|
|
# Flag should be reset to False
|
|
assert util._cleanup_in_progress == False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|