|
22 | 22 | SUBJECT_ALLOWED, |
23 | 23 | SUBJECT_REJECTED, |
24 | 24 | QuestionValidity, |
| 25 | + _extract_conversation_id, |
25 | 26 | _extract_message_str_from_user_content, |
26 | 27 | ) |
27 | 28 |
|
@@ -65,6 +66,47 @@ def test_sequence_with_non_text_content(self) -> None: |
65 | 66 | assert result == "keep" |
66 | 67 |
|
67 | 68 |
|
| 69 | +class TestExtractConversationId: |
| 70 | + """Tests for _extract_conversation_id helper.""" |
| 71 | + |
| 72 | + def test_extracts_conversation_id(self, mocker: MockerFixture) -> None: |
| 73 | + """Test extraction when extra_body.conversation is set.""" |
| 74 | + model = mocker.Mock() |
| 75 | + model.settings = {"extra_body": {"conversation": "conv_123"}} |
| 76 | + |
| 77 | + assert _extract_conversation_id(model) == "conv_123" |
| 78 | + |
| 79 | + def test_returns_none_when_settings_missing(self, mocker: MockerFixture) -> None: |
| 80 | + """Test that None settings yields None.""" |
| 81 | + model = mocker.Mock() |
| 82 | + model.settings = None |
| 83 | + |
| 84 | + assert _extract_conversation_id(model) is None |
| 85 | + |
| 86 | + def test_returns_none_when_extra_body_missing(self, mocker: MockerFixture) -> None: |
| 87 | + """Test that missing extra_body yields None.""" |
| 88 | + model = mocker.Mock() |
| 89 | + model.settings = {} |
| 90 | + |
| 91 | + assert _extract_conversation_id(model) is None |
| 92 | + |
| 93 | + def test_returns_none_when_extra_body_not_dict(self, mocker: MockerFixture) -> None: |
| 94 | + """Test that a non-dict extra_body yields None instead of raising.""" |
| 95 | + model = mocker.Mock() |
| 96 | + model.settings = {"extra_body": "not-a-dict"} |
| 97 | + |
| 98 | + assert _extract_conversation_id(model) is None |
| 99 | + |
| 100 | + def test_returns_none_when_conversation_not_string( |
| 101 | + self, mocker: MockerFixture |
| 102 | + ) -> None: |
| 103 | + """Test that a non-string conversation value yields None.""" |
| 104 | + model = mocker.Mock() |
| 105 | + model.settings = {"extra_body": {"conversation": 123}} |
| 106 | + |
| 107 | + assert _extract_conversation_id(model) is None |
| 108 | + |
| 109 | + |
68 | 110 | class TestQuestionValidityConfigInit: |
69 | 111 | """Tests for QuestionValidityConfig initialization.""" |
70 | 112 |
|
@@ -216,12 +258,21 @@ def _mock_create_model(self, mocker: MockerFixture) -> None: |
216 | 258 | mocker.patch(f"{_MODULE}.AsyncOgxClientHolder") |
217 | 259 | mocker.patch(f"{_MODULE}.OgxResponsesModel.from_ogx_client") |
218 | 260 |
|
| 261 | + @pytest.fixture(name="mock_append_turn", autouse=True) |
| 262 | + def mock_append_turn_fixture(self, mocker: MockerFixture) -> MockType: |
| 263 | + """Mock the conversation-persistence call used on rejection.""" |
| 264 | + return mocker.patch( |
| 265 | + f"{_MODULE}.append_turn_to_conversation", new_callable=mocker.AsyncMock |
| 266 | + ) |
| 267 | + |
219 | 268 | @pytest.fixture(name="mock_ctx") |
220 | 269 | def mock_ctx_fixture(self, mocker: MockerFixture) -> RunContext: |
221 | | - """Create a mock RunContext.""" |
| 270 | + """Create a mock RunContext bound to a model with a conversation ID.""" |
222 | 271 | ctx = mocker.Mock(spec=RunContext) |
223 | 272 | ctx.prompt = "How do I create a pod?" |
224 | 273 | ctx.usage = RunUsage() |
| 274 | + ctx.model = mocker.Mock() |
| 275 | + ctx.model.settings = {"extra_body": {"conversation": "conv_test"}} |
225 | 276 | return ctx |
226 | 277 |
|
227 | 278 | @pytest.fixture(name="mock_handler") |
@@ -280,6 +331,89 @@ async def test_rejected_question_returns_rejection( |
280 | 331 | assert isinstance(result, AgentRunResult) |
281 | 332 | assert result.output == DEFAULT_INVALID_QUESTION_RESPONSE |
282 | 333 |
|
| 334 | + @pytest.mark.asyncio |
| 335 | + async def test_rejected_question_persists_turn_to_conversation( |
| 336 | + self, |
| 337 | + mocker: MockerFixture, |
| 338 | + mock_ctx: RunContext, |
| 339 | + mock_handler: MockType, |
| 340 | + mock_append_turn: MockType, |
| 341 | + ) -> None: |
| 342 | + """Test that a rejection appends the user question and refusal to the conversation.""" |
| 343 | + mock_client = mocker.Mock() |
| 344 | + mocker.patch( |
| 345 | + f"{_MODULE}.AsyncOgxClientHolder" |
| 346 | + ).return_value.get_client.return_value = mock_client |
| 347 | + mock_response = ModelResponse( |
| 348 | + parts=[TextPart(content=SUBJECT_REJECTED)], |
| 349 | + usage=RequestUsage(input_tokens=10, output_tokens=1), |
| 350 | + ) |
| 351 | + mocker.patch( |
| 352 | + "pydantic_ai_lightspeed.capabilities.question_validity._capability.model_request", |
| 353 | + return_value=mock_response, |
| 354 | + ) |
| 355 | + |
| 356 | + config = QuestionValidityConfig(model_id="test") |
| 357 | + qv = QuestionValidity(config=config) |
| 358 | + await qv.wrap_run(mock_ctx, handler=mock_handler) |
| 359 | + |
| 360 | + mock_append_turn.assert_awaited_once_with( |
| 361 | + mock_client, |
| 362 | + "conv_test", |
| 363 | + "How do I create a pod?", |
| 364 | + DEFAULT_INVALID_QUESTION_RESPONSE, |
| 365 | + ) |
| 366 | + |
| 367 | + @pytest.mark.asyncio |
| 368 | + async def test_rejection_skips_persistence_when_conversation_id_missing( |
| 369 | + self, |
| 370 | + mocker: MockerFixture, |
| 371 | + mock_ctx: RunContext, |
| 372 | + mock_handler: MockType, |
| 373 | + mock_append_turn: MockType, |
| 374 | + ) -> None: |
| 375 | + """Test that persistence is skipped (not crashed) without a conversation ID.""" |
| 376 | + mock_ctx.model = mocker.Mock(settings={}) |
| 377 | + mock_response = ModelResponse( |
| 378 | + parts=[TextPart(content=SUBJECT_REJECTED)], |
| 379 | + usage=RequestUsage(), |
| 380 | + ) |
| 381 | + mocker.patch( |
| 382 | + "pydantic_ai_lightspeed.capabilities.question_validity._capability.model_request", |
| 383 | + return_value=mock_response, |
| 384 | + ) |
| 385 | + |
| 386 | + config = QuestionValidityConfig(model_id="test") |
| 387 | + qv = QuestionValidity(config=config) |
| 388 | + result = await qv.wrap_run(mock_ctx, handler=mock_handler) |
| 389 | + |
| 390 | + mock_append_turn.assert_not_awaited() |
| 391 | + assert result.output == DEFAULT_INVALID_QUESTION_RESPONSE |
| 392 | + |
| 393 | + @pytest.mark.asyncio |
| 394 | + async def test_allowed_question_does_not_persist_turn( |
| 395 | + self, |
| 396 | + mocker: MockerFixture, |
| 397 | + mock_ctx: RunContext, |
| 398 | + mock_handler: MockType, |
| 399 | + mock_append_turn: MockType, |
| 400 | + ) -> None: |
| 401 | + """Test that an allowed question does not touch the conversation.""" |
| 402 | + mock_response = ModelResponse( |
| 403 | + parts=[TextPart(content=SUBJECT_ALLOWED)], |
| 404 | + usage=RequestUsage(input_tokens=10, output_tokens=1), |
| 405 | + ) |
| 406 | + mocker.patch( |
| 407 | + "pydantic_ai_lightspeed.capabilities.question_validity._capability.model_request", |
| 408 | + return_value=mock_response, |
| 409 | + ) |
| 410 | + |
| 411 | + config = QuestionValidityConfig(model_id="test") |
| 412 | + qv = QuestionValidity(config=config) |
| 413 | + await qv.wrap_run(mock_ctx, handler=mock_handler) |
| 414 | + |
| 415 | + mock_append_turn.assert_not_awaited() |
| 416 | + |
283 | 417 | @pytest.mark.asyncio |
284 | 418 | async def test_unexpected_response_treated_as_rejected( |
285 | 419 | self, |
@@ -469,6 +603,8 @@ async def test_wrap_run_with_none_prompt( |
469 | 603 | ctx = mocker.Mock(spec=RunContext) |
470 | 604 | ctx.prompt = None |
471 | 605 | ctx.usage = RunUsage() |
| 606 | + ctx.model = mocker.Mock() |
| 607 | + ctx.model.settings = {"extra_body": {"conversation": "conv_test"}} |
472 | 608 |
|
473 | 609 | mock_response = ModelResponse( |
474 | 610 | parts=[TextPart(content=SUBJECT_REJECTED)], |
|
0 commit comments