Per-subsystem invariants for the eight ported sites (relay outbox claim, lightpanda reaper, write-approval pending store, spawn_tree list/load, local-runtime manifest, A2A conversation replay, batch runner scans, trajectory compressor entry), trimmed from PR #114241's test hunks to at most two per subsystem (batch_runner's dataset-load and resume-scan cases collapsed into one). Each was red on the previous head of this branch. (cherry picked from commit d4b54568887e69b3ee3d363ebe4dcd657ccf64f9)
213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
"""Tests for trajectory_compressor AsyncOpenAI event loop binding.
|
|
|
|
The AsyncOpenAI client was created once at __init__ time and stored as an
|
|
instance attribute. When process_directory() calls asyncio.run() — which
|
|
creates and closes a fresh event loop — the client's internal httpx
|
|
transport remains bound to the now-closed loop. A second call to
|
|
process_directory() would fail with "Event loop is closed".
|
|
|
|
The fix creates the AsyncOpenAI client lazily via _get_async_client() so
|
|
each asyncio.run() gets a client bound to the current loop.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestAsyncClientLazyCreation:
|
|
"""trajectory_compressor.py — _get_async_client()"""
|
|
|
|
def test_async_client_none_after_init(self):
|
|
"""async_client should be None after __init__ (not eagerly created)."""
|
|
from trajectory_compressor import TrajectoryCompressor
|
|
|
|
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
comp.config = MagicMock()
|
|
comp.config.base_url = "https://api.example.com/v1"
|
|
comp.config.api_key_env = "TEST_API_KEY"
|
|
comp._use_call_llm = False
|
|
comp.async_client = None
|
|
comp._async_client_api_key = "test-key"
|
|
|
|
assert comp.async_client is None
|
|
|
|
def test_get_async_client_creates_new_client(self):
|
|
"""_get_async_client() should create a fresh AsyncOpenAI instance."""
|
|
from trajectory_compressor import TrajectoryCompressor
|
|
|
|
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
comp.config = MagicMock()
|
|
comp.config.base_url = "https://api.example.com/v1"
|
|
comp._async_client_api_key = "test-key"
|
|
comp.async_client = None
|
|
|
|
mock_async_openai = MagicMock()
|
|
with patch("openai.AsyncOpenAI", mock_async_openai):
|
|
client = comp._get_async_client()
|
|
|
|
mock_async_openai.assert_called_once_with(
|
|
api_key="test-key",
|
|
base_url="https://api.example.com/v1",
|
|
)
|
|
assert comp.async_client is not None
|
|
|
|
def test_get_async_client_creates_fresh_each_call(self):
|
|
"""Each call to _get_async_client() creates a NEW client instance,
|
|
so it binds to the current event loop."""
|
|
from trajectory_compressor import TrajectoryCompressor
|
|
|
|
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
comp.config = MagicMock()
|
|
comp.config.base_url = "https://api.example.com/v1"
|
|
comp._async_client_api_key = "test-key"
|
|
comp.async_client = None
|
|
|
|
call_count = 0
|
|
instances = []
|
|
|
|
def mock_constructor(**kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
instance = MagicMock()
|
|
instances.append(instance)
|
|
return instance
|
|
|
|
with patch("openai.AsyncOpenAI", side_effect=mock_constructor):
|
|
client1 = comp._get_async_client()
|
|
client2 = comp._get_async_client()
|
|
|
|
# Should have created two separate instances
|
|
assert call_count == 2
|
|
assert instances[0] is not instances[1]
|
|
|
|
|
|
class TestSourceLineVerification:
|
|
"""Verify the actual source has the lazy pattern applied."""
|
|
|
|
@staticmethod
|
|
def _read_file() -> str:
|
|
import os
|
|
base = os.path.dirname(os.path.dirname(__file__))
|
|
with open(os.path.join(base, "trajectory_compressor.py")) as f:
|
|
return f.read()
|
|
|
|
def test_no_eager_async_openai_in_init(self):
|
|
"""__init__ should NOT create AsyncOpenAI eagerly."""
|
|
src = self._read_file()
|
|
# The old pattern: self.async_client = AsyncOpenAI(...) in _init_summarizer
|
|
# should not exist — only self.async_client = None
|
|
lines = src.split("\n")
|
|
for i, line in enumerate(lines, 1):
|
|
if "self.async_client = AsyncOpenAI(" in line and "_get_async_client" not in lines[max(0,i-3):i+1]:
|
|
# Allow it inside _get_async_client method
|
|
# Check if we're inside _get_async_client by looking at context
|
|
context = "\n".join(lines[max(0,i-20):i+1])
|
|
if "_get_async_client" not in context:
|
|
pytest.fail(
|
|
f"Line {i}: AsyncOpenAI created eagerly outside _get_async_client()"
|
|
)
|
|
|
|
def test_get_async_client_method_exists(self):
|
|
"""_get_async_client method should exist."""
|
|
src = self._read_file()
|
|
assert "def _get_async_client(self)" in src
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_summary_async_kimi_omits_temperature():
|
|
"""Kimi models should have temperature omitted — server manages it."""
|
|
from trajectory_compressor import CompressionConfig, TrajectoryCompressor, TrajectoryMetrics
|
|
|
|
config = CompressionConfig(
|
|
summarization_model="kimi-for-coding",
|
|
temperature=0.3,
|
|
summary_target_tokens=100,
|
|
max_retries=1,
|
|
)
|
|
compressor = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
compressor.config = config
|
|
compressor.logger = MagicMock()
|
|
compressor._use_call_llm = False
|
|
async_client = MagicMock()
|
|
async_client.chat.completions.create = MagicMock(return_value=SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="[CONTEXT SUMMARY]: summary"))]
|
|
))
|
|
compressor._get_async_client = MagicMock(return_value=async_client)
|
|
|
|
metrics = TrajectoryMetrics()
|
|
result = await compressor._generate_summary_async("tool output", metrics)
|
|
|
|
assert result.startswith("[CONTEXT SUMMARY]:")
|
|
assert "temperature" not in async_client.chat.completions.create.call_args.kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_summary_async_public_moonshot_kimi_k2_5_omits_temperature():
|
|
"""kimi-k2.5 on the public Moonshot API should not get a forced temperature."""
|
|
from trajectory_compressor import CompressionConfig, TrajectoryCompressor, TrajectoryMetrics
|
|
|
|
config = CompressionConfig(
|
|
summarization_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.ai/v1",
|
|
temperature=0.3,
|
|
summary_target_tokens=100,
|
|
max_retries=1,
|
|
)
|
|
compressor = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
compressor.config = config
|
|
compressor.logger = MagicMock()
|
|
compressor._use_call_llm = False
|
|
async_client = MagicMock()
|
|
async_client.chat.completions.create = MagicMock(return_value=SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="[CONTEXT SUMMARY]: summary"))]
|
|
))
|
|
compressor._get_async_client = MagicMock(return_value=async_client)
|
|
|
|
metrics = TrajectoryMetrics()
|
|
result = await compressor._generate_summary_async("tool output", metrics)
|
|
|
|
assert result.startswith("[CONTEXT SUMMARY]:")
|
|
assert "temperature" not in async_client.chat.completions.create.call_args.kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_summary_async_public_moonshot_cn_kimi_k2_5_omits_temperature():
|
|
"""kimi-k2.5 on api.moonshot.cn should not get a forced temperature."""
|
|
from trajectory_compressor import CompressionConfig, TrajectoryCompressor, TrajectoryMetrics
|
|
|
|
config = CompressionConfig(
|
|
summarization_model="kimi-k2.5",
|
|
base_url="https://api.moonshot.cn/v1",
|
|
temperature=0.3,
|
|
summary_target_tokens=100,
|
|
max_retries=1,
|
|
)
|
|
compressor = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
compressor.config = config
|
|
compressor.logger = MagicMock()
|
|
compressor._use_call_llm = False
|
|
async_client = MagicMock()
|
|
async_client.chat.completions.create = MagicMock(return_value=SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="[CONTEXT SUMMARY]: summary"))]
|
|
))
|
|
compressor._get_async_client = MagicMock(return_value=async_client)
|
|
|
|
metrics = TrajectoryMetrics()
|
|
result = await compressor._generate_summary_async("tool output", metrics)
|
|
|
|
assert result.startswith("[CONTEXT SUMMARY]:")
|
|
assert "temperature" not in async_client.chat.completions.create.call_args.kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_entry_async_passes_non_dict_through():
|
|
"""A scalar JSONL line used to crash on '"conversations" not in entry';
|
|
unknown shapes pass through byte-faithful."""
|
|
from trajectory_compressor import TrajectoryCompressor
|
|
|
|
compressor = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
|
entry, metrics = await compressor.process_entry_async(42)
|
|
assert entry == 42
|