personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_batch.py
21 kB 661 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for the Batch async batch processor.""" 5 6import asyncio 7from unittest.mock import AsyncMock, patch 8 9import pytest 10 11from solstone.think.batch import Batch, BatchRequest 12from solstone.think.models import GEMINI_FLASH, SchemaValidationError 13 14 15def _result(text: str = "Response", finish_reason: str = "stop", **extra): 16 return {"text": text, "finish_reason": finish_reason, **extra} 17 18 19def test_batch_request_creation(): 20 """Test BatchRequest can be created with required and custom params.""" 21 # Required params only 22 req = BatchRequest(contents="Test prompt", context="test.context") 23 assert req.contents == "Test prompt" 24 assert req.context == "test.context" 25 assert req.temperature == 0.3 26 assert req.response is None 27 assert req.error is None 28 29 # With generation options 30 req2 = BatchRequest( 31 contents=["Part 1", "Part 2"], 32 context="test.context", 33 temperature=0.7, 34 json_output=True, 35 ) 36 assert req2.contents == ["Part 1", "Part 2"] 37 assert req2.temperature == 0.7 38 assert req2.json_output is True 39 40 41def test_batch_request_custom_attributes(): 42 """Test that arbitrary attributes can be added to BatchRequest.""" 43 req = BatchRequest(contents="Test", context="test.context") 44 req.frame_id = 123 45 req.stage = "initial" 46 req.custom_data = {"foo": "bar"} 47 48 assert req.frame_id == 123 49 assert req.stage == "initial" 50 assert req.custom_data == {"foo": "bar"} 51 52 53@pytest.mark.asyncio 54@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 55async def test_batch_basic(mock_agenerate): 56 """Test basic batch execution with single request.""" 57 mock_agenerate.return_value = _result("Response 1") 58 59 # Create batch and add request 60 batch = Batch(max_concurrent=5) 61 req = batch.create(contents="What is 2+2?", context="test.calc") 62 req.task_id = "calc1" 63 batch.add(req) 64 65 # Iterate and verify 66 results = [] 67 async for completed_req in batch.drain_batch(): 68 results.append(completed_req) 69 70 assert len(results) == 1 71 assert results[0].task_id == "calc1" 72 assert results[0].response == "Response 1" 73 assert results[0].error is None 74 assert results[0].duration > 0 75 76 # Verify API was called correctly 77 mock_agenerate.assert_called_once() 78 call_kwargs = mock_agenerate.call_args[1] 79 assert call_kwargs["contents"] == "What is 2+2?" 80 assert call_kwargs["context"] == "test.calc" 81 82 83@pytest.mark.asyncio 84@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 85async def test_batch_records_resolved_model(mock_agenerate): 86 """The transport-reported model is recorded without a request override.""" 87 mock_agenerate.return_value = _result("Response", model=GEMINI_FLASH) 88 89 batch = Batch(max_concurrent=5) 90 req = batch.create(contents="Test", context="test.context") 91 batch.add(req) 92 93 results = [] 94 async for completed_req in batch.drain_batch(): 95 results.append(completed_req) 96 97 assert len(results) == 1 98 assert results[0].model_used == GEMINI_FLASH 99 100 call_kwargs = mock_agenerate.call_args[1] 101 assert "model" not in call_kwargs 102 103 104@pytest.mark.asyncio 105@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 106async def test_batch_multiple_requests(mock_agenerate): 107 """Test batch with multiple requests.""" 108 mock_agenerate.side_effect = [ 109 _result("Response 1"), 110 _result("Response 2"), 111 _result("Response 3"), 112 ] 113 114 batch = Batch(max_concurrent=2) 115 116 # Add multiple requests 117 req1 = batch.create(contents="Prompt 1", context="test.context") 118 req1.id = 1 119 batch.add(req1) 120 121 req2 = batch.create(contents="Prompt 2", context="test.context") 122 req2.id = 2 123 batch.add(req2) 124 125 req3 = batch.create(contents="Prompt 3", context="test.context") 126 req3.id = 3 127 batch.add(req3) 128 129 # Collect results 130 results = [] 131 async for req in batch.drain_batch(): 132 results.append(req) 133 134 # Should have all 3 results 135 assert len(results) == 3 136 137 # Results may come in any order (concurrent execution) 138 result_ids = {r.id for r in results} 139 assert result_ids == {1, 2, 3} 140 141 # All should have responses 142 for r in results: 143 assert r.response is not None 144 assert r.error is None 145 146 147@pytest.mark.asyncio 148@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 149async def test_batch_error_handling(mock_agenerate): 150 """Test that errors are captured in request.error.""" 151 mock_agenerate.side_effect = ValueError("API error") 152 153 batch = Batch(max_concurrent=5) 154 req = batch.create(contents="Bad prompt", context="test.context") 155 req.id = "error_test" 156 batch.add(req) 157 158 # Get result 159 results = [] 160 async for r in batch.drain_batch(): 161 results.append(r) 162 163 assert len(results) == 1 164 assert results[0].id == "error_test" 165 assert results[0].response is None 166 assert results[0].error == "API error" 167 assert results[0].duration > 0 168 169 170@pytest.mark.asyncio 171@patch("solstone.think.batch.resolve_provider") 172@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 173async def test_batch_preserves_exception_metadata( 174 mock_agenerate, mock_resolve_provider 175): 176 """Structured provider metadata should survive the batch error path.""" 177 178 class ProviderError(RuntimeError): 179 reason_code = "provider_key_invalid" 180 reset_at_ms = 12345 181 provider = "anthropic" 182 183 mock_agenerate.side_effect = ProviderError("bad key") 184 mock_resolve_provider.return_value = ("google", "unused") 185 186 batch = Batch(max_concurrent=5) 187 req = batch.create(contents="Bad prompt", context="test.context") 188 batch.add(req) 189 190 results = [] 191 async for r in batch.drain_batch(): 192 results.append(r) 193 194 assert len(results) == 1 195 assert results[0].error == "bad key" 196 assert results[0].reason_code == "provider_key_invalid" 197 assert results[0].reset_at_ms == 12345 198 assert results[0].provider == "anthropic" 199 mock_resolve_provider.assert_not_called() 200 201 202@pytest.mark.asyncio 203@patch("solstone.think.batch.resolve_provider") 204@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 205async def test_batch_classifies_exception_metadata_when_attrs_missing( 206 mock_agenerate, mock_resolve_provider 207): 208 """Batch should classify generic exceptions and best-effort provider.""" 209 mock_agenerate.side_effect = ConnectionError("network down") 210 mock_resolve_provider.return_value = ("google", "gemini-test") 211 212 batch = Batch(max_concurrent=5) 213 req = batch.create(contents="Bad prompt", context="test.context") 214 batch.add(req) 215 216 results = [] 217 async for r in batch.drain_batch(): 218 results.append(r) 219 220 assert len(results) == 1 221 assert results[0].reason_code == "network_unreachable" 222 assert results[0].reset_at_ms is None 223 assert results[0].provider == "google" 224 mock_resolve_provider.assert_called_once_with("generate") 225 226 227def test_batch_update_clears_error_metadata(): 228 batch = Batch(max_concurrent=5) 229 req = batch.create(contents="Retry prompt", context="test.context") 230 req.reason_code = "provider_key_invalid" 231 req.provider = "anthropic" 232 req.reset_at_ms = 12345 233 req.error = "bad key" 234 235 with patch.object(batch, "add") as mock_add: 236 batch.update(req) 237 238 assert req.reason_code is None 239 assert req.provider is None 240 assert req.reset_at_ms is None 241 assert req.error is None 242 mock_add.assert_called_once_with(req) 243 244 245@pytest.mark.asyncio 246@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 247async def test_batch_dynamic_adding(mock_agenerate): 248 """Test adding requests dynamically during iteration.""" 249 mock_agenerate.return_value = _result("Response") 250 251 batch = Batch(max_concurrent=5) 252 253 # Add initial request 254 req1 = batch.create(contents="Initial", context="test.context") 255 req1.stage = "initial" 256 batch.add(req1) 257 258 # Process and add more during iteration 259 results = [] 260 added_followup = False 261 262 async for req in batch.drain_batch(): 263 results.append(req) 264 265 # After first result, add a follow-up 266 if req.stage == "initial" and not added_followup: 267 req2 = batch.create(contents="Follow-up", context="test.context") 268 req2.stage = "followup" 269 batch.add(req2) 270 added_followup = True 271 272 # Should have both results 273 assert len(results) == 2 274 stages = {r.stage for r in results} 275 assert stages == {"initial", "followup"} 276 277 278@pytest.mark.asyncio 279@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 280async def test_batch_retry_pattern(mock_agenerate): 281 """Test retry pattern without bypassing the active model profile.""" 282 # First call fails, second succeeds 283 call_count = 0 284 285 async def mock_response(*args, **kwargs): 286 nonlocal call_count 287 call_count += 1 288 if call_count == 1: 289 raise ValueError("Transient error") 290 return _result("Success on retry") 291 292 mock_agenerate.side_effect = mock_response 293 294 batch = Batch(max_concurrent=5) 295 296 # Add initial request 297 req1 = batch.create(contents="Test", context="test.context") 298 req1.attempt = 1 299 batch.add(req1) 300 301 results = [] 302 async for req in batch.drain_batch(): 303 results.append(req) 304 305 # If error, retry through the same active profile. 306 if req.error and req.attempt == 1: 307 retry = batch.create( 308 contents=req.contents, 309 context="test.context", 310 ) 311 retry.attempt = 2 312 batch.add(retry) 313 314 # Should have both attempts 315 assert len(results) == 2 316 assert results[0].error is not None 317 assert results[0].attempt == 1 318 assert results[1].response == "Success on retry" 319 assert results[1].attempt == 2 320 321 322@pytest.mark.asyncio 323@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 324async def test_batch_factory_method(mock_agenerate): 325 """Test that batch.create() factory method works correctly.""" 326 mock_agenerate.return_value = _result("Response") 327 328 batch = Batch() 329 330 # Use factory method 331 req = batch.create( 332 contents="Test", 333 context="test.context", 334 temperature=0.8, 335 json_output=True, 336 ) 337 338 assert isinstance(req, BatchRequest) 339 assert req.contents == "Test" 340 assert req.context == "test.context" 341 assert req.temperature == 0.8 342 assert req.json_output is True 343 344 345@pytest.mark.asyncio 346@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 347async def test_batch_can_add_after_draining(mock_agenerate): 348 """Test that adding after draining works (reusable batch).""" 349 mock_agenerate.side_effect = [_result("Response 1"), _result("Response 2")] 350 351 batch = Batch() 352 353 # First batch 354 req1 = batch.create(contents="First", context="test.context") 355 req1.id = 1 356 batch.add(req1) 357 358 results = [] 359 async for req in batch.drain_batch(): 360 results.append(req) 361 362 assert len(results) == 1 363 assert results[0].id == 1 364 365 # Add more work after draining 366 req2 = batch.create(contents="Second", context="test.context") 367 req2.id = 2 368 batch.add(req2) 369 370 async for req in batch.drain_batch(): 371 results.append(req) 372 373 # Should have both results 374 assert len(results) == 2 375 assert {r.id for r in results} == {1, 2} 376 377 378@pytest.mark.asyncio 379@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 380async def test_batch_empty_batch(mock_agenerate): 381 """Test that empty batch (no requests) completes immediately.""" 382 batch = Batch() 383 384 results = [] 385 async for req in batch.drain_batch(): 386 results.append(req) 387 388 assert len(results) == 0 389 390 391@pytest.mark.asyncio 392@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 393async def test_batch_concurrency_limit(mock_agenerate): 394 """Test that semaphore limits concurrent requests.""" 395 # Track concurrent calls 396 concurrent_calls = 0 397 max_concurrent_seen = 0 398 lock = asyncio.Lock() 399 400 async def mock_with_tracking(*args, **kwargs): 401 nonlocal concurrent_calls, max_concurrent_seen 402 async with lock: 403 concurrent_calls += 1 404 max_concurrent_seen = max(max_concurrent_seen, concurrent_calls) 405 406 await asyncio.sleep(0.1) # Simulate API call 407 408 async with lock: 409 concurrent_calls -= 1 410 411 return _result("Response") 412 413 mock_agenerate.side_effect = mock_with_tracking 414 415 # Create batch with max_concurrent=2 416 batch = Batch(max_concurrent=2) 417 418 # Add 5 requests 419 for i in range(5): 420 req = batch.create(contents=f"Request {i}", context="test.context") 421 batch.add(req) 422 423 results = [] 424 async for req in batch.drain_batch(): 425 results.append(req) 426 427 assert len(results) == 5 428 # Should never exceed max_concurrent=2 429 assert max_concurrent_seen <= 2 430 431 432@pytest.mark.asyncio 433@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 434async def test_batch_update_method(mock_agenerate): 435 """Test batch.update() method for modifying and re-adding requests.""" 436 call_temperatures = [] 437 438 async def mock_track_options(*args, **kwargs): 439 call_temperatures.append(kwargs["temperature"]) 440 return _result(f"Response at {kwargs['temperature']}") 441 442 mock_agenerate.side_effect = mock_track_options 443 444 batch = Batch(max_concurrent=5) 445 446 # Create initial request 447 req = batch.create(contents="Initial prompt", context="test.context") 448 req.stage = "initial" 449 batch.add(req) 450 451 results = [] 452 result_count = 0 453 async for completed_req in batch.drain_batch(): 454 result_count += 1 455 # Capture the response at this point 456 results.append((result_count, completed_req.response, completed_req.stage)) 457 458 # After first result, update generation options and re-add. 459 if result_count == 1: 460 batch.update( 461 completed_req, 462 contents="Updated prompt", 463 temperature=0.8, 464 stage="updated", # Update custom attribute too 465 custom_field="test_value", # Add new custom attribute 466 ) 467 468 # Should have both results 469 assert len(results) == 2 470 assert results[0][2] == "initial" # First result was initial stage 471 assert results[1][2] == "updated" # Second result was updated stage 472 473 assert call_temperatures == [0.3, 0.8] 474 475 # Verify correct responses at each stage 476 assert results[0][1] == "Response at 0.3" 477 assert results[1][1] == "Response at 0.8" 478 479 # Verify custom attribute was set 480 assert req.custom_field == "test_value" 481 482 483def test_batch_request_with_timeout(): 484 """Test BatchRequest can be created with timeout_s parameter.""" 485 req = BatchRequest(contents="Test prompt", context="test.context", timeout_s=30) 486 assert req.timeout_s == 30 487 488 req2 = BatchRequest(contents="Test prompt", context="test.context", timeout_s=60.5) 489 assert req2.timeout_s == 60.5 490 491 # Default is None 492 req3 = BatchRequest(contents="Test prompt", context="test.context") 493 assert req3.timeout_s is None 494 495 496@pytest.mark.asyncio 497@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 498async def test_batch_timeout_passthrough(mock_agenerate): 499 """Test that timeout_s is passed through to agenerate_with_result.""" 500 mock_agenerate.return_value = _result("Response") 501 502 batch = Batch(max_concurrent=5) 503 504 # Create request with timeout_s 505 req = batch.create(contents="Test prompt", context="test.context", timeout_s=45) 506 batch.add(req) 507 508 results = [] 509 async for completed_req in batch.drain_batch(): 510 results.append(completed_req) 511 512 assert len(results) == 1 513 514 # Verify timeout_s was passed to agenerate_with_result 515 mock_agenerate.assert_called_once() 516 call_kwargs = mock_agenerate.call_args[1] 517 assert call_kwargs["timeout_s"] == 45 518 519 520def test_batch_rejects_provider_specific_client_override(): 521 with pytest.raises(TypeError, match="unexpected keyword argument 'client'"): 522 Batch(max_concurrent=5, client=object()) 523 524 525@pytest.mark.asyncio 526@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 527async def test_batch_passes_json_schema_to_agenerate(mock_agenerate): 528 """Test that json_schema is passed through to agenerate_with_result.""" 529 mock_agenerate.return_value = _result("Response") 530 531 batch = Batch(max_concurrent=5) 532 req = batch.create( 533 contents="Test prompt", 534 context="test.context", 535 json_schema={"type": "object"}, 536 ) 537 batch.add(req) 538 539 results = [] 540 async for completed_req in batch.drain_batch(): 541 results.append(completed_req) 542 543 assert len(results) == 1 544 545 call_kwargs = mock_agenerate.call_args[1] 546 assert call_kwargs["json_schema"] == {"type": "object"} 547 548 549@pytest.mark.asyncio 550@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 551async def test_batch_schema_validation_error_populates_request_error(mock_agenerate): 552 mock_agenerate.side_effect = SchemaValidationError( 553 [{"path": "", "constraint": "json_parse", "message": "empty"}], 554 "", 555 ) 556 557 batch = Batch(max_concurrent=5) 558 req = batch.create( 559 contents="Test prompt", 560 context="test.context", 561 json_schema={"type": "object"}, 562 ) 563 batch.add(req) 564 565 results = [] 566 async for completed_req in batch.drain_batch(): 567 results.append(completed_req) 568 569 assert len(results) == 1 570 assert results[0].response is None 571 assert "schema validation" in results[0].error 572 573 574@pytest.mark.asyncio 575@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 576async def test_batch_non_json_length_finish_populates_text_length_error(mock_agenerate): 577 mock_agenerate.return_value = _result("partial text", finish_reason="max_tokens") 578 579 batch = Batch(max_concurrent=5) 580 req = batch.create(contents="Test prompt", context="test.context") 581 batch.add(req) 582 583 results = [] 584 async for completed_req in batch.drain_batch(): 585 results.append(completed_req) 586 587 assert len(results) == 1 588 assert results[0].response is None 589 assert results[0].reason_code == "incomplete_text_length" 590 assert "Text response incomplete" in results[0].error 591 592 593@pytest.mark.asyncio 594@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 595async def test_batch_non_json_stop_finish_succeeds(mock_agenerate): 596 mock_agenerate.return_value = _result("complete text", finish_reason="stop") 597 598 batch = Batch(max_concurrent=1) 599 req = batch.create( 600 contents="Test", 601 context="test.context", 602 json_output=False, 603 ) 604 batch.add(req) 605 606 results = [] 607 async for completed_req in batch.drain_batch(): 608 results.append(completed_req) 609 610 assert len(results) == 1 611 assert req.response == "complete text" 612 assert req.error is None 613 assert req.reason_code is None 614 615 616@pytest.mark.asyncio 617@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 618async def test_batch_non_json_non_length_finish_is_provider_response_invalid( 619 mock_agenerate, 620): 621 mock_agenerate.return_value = _result("", finish_reason="content_filter") 622 623 batch = Batch(max_concurrent=5) 624 req = batch.create(contents="Test prompt", context="test.context") 625 batch.add(req) 626 627 results = [] 628 async for completed_req in batch.drain_batch(): 629 results.append(completed_req) 630 631 assert len(results) == 1 632 assert results[0].response is None 633 assert results[0].reason_code == "provider_response_invalid" 634 635 636@pytest.mark.asyncio 637@patch("solstone.think.batch.agenerate_with_result", new_callable=AsyncMock) 638async def test_batch_full_result_schema_invalid_stays_hard_failure(mock_agenerate): 639 mock_agenerate.return_value = _result( 640 '{"field": "bad"}', 641 schema_validation={ 642 "valid": False, 643 "errors": [{"path": "/field", "constraint": "type", "message": "bad"}], 644 }, 645 ) 646 647 batch = Batch(max_concurrent=5) 648 req = batch.create( 649 contents="Test prompt", 650 context="test.context", 651 json_schema={"type": "object"}, 652 ) 653 batch.add(req) 654 655 results = [] 656 async for completed_req in batch.drain_batch(): 657 results.append(completed_req) 658 659 assert len(results) == 1 660 assert results[0].response is None 661 assert "schema validation" in results[0].error