|
9 | 9 | from __future__ import annotations |
10 | 10 |
|
11 | 11 | import logging |
| 12 | +from types import SimpleNamespace |
12 | 13 | from typing import Any |
13 | 14 |
|
| 15 | +import pytest |
| 16 | +from amplifier_core import ChatRequest, ChatResponse, ThinkingBlock, ThinkingContent |
| 17 | +from amplifier_core.message_models import Message |
| 18 | +from pydantic import BaseModel |
| 19 | + |
| 20 | +from amplifier_module_provider_github_copilot.request_adapter import convert_chat_request |
| 21 | +from amplifier_module_provider_github_copilot.sdk_adapter import extract_event_fields |
14 | 22 | from amplifier_module_provider_github_copilot.streaming import ( |
15 | 23 | DomainEvent, |
16 | 24 | DomainEventType, |
17 | 25 | StreamingAccumulator, |
| 26 | + load_event_config, |
| 27 | + translate_event, |
18 | 28 | ) |
19 | 29 |
|
20 | 30 |
|
@@ -364,6 +374,134 @@ def test_thinking_block_handles_no_signature(self) -> None: |
364 | 374 | # signature should be None (not missing, not error) |
365 | 375 | assert thinking_block.signature is None |
366 | 376 |
|
| 377 | + @pytest.mark.parametrize("empty_first", [False, True], ids=["empty-after", "empty-before"]) |
| 378 | + @pytest.mark.parametrize("thinking", ["Visible reasoning", ""], ids=["visible", "opaque-only"]) |
| 379 | + def test_empty_unsigned_block_does_not_inherit_retention_from_separate_block( |
| 380 | + self, empty_first: bool, thinking: str |
| 381 | + ) -> None: |
| 382 | + """Only locally captured opaque state can retain an empty thinking block.""" |
| 383 | + unsigned = DomainEvent( |
| 384 | + type=DomainEventType.CONTENT_DELTA, data={"text": ""}, block_type="THINKING" |
| 385 | + ) |
| 386 | + signed = DomainEvent( |
| 387 | + type=DomainEventType.CONTENT_DELTA, |
| 388 | + data={"text": thinking, "reasoning_opaque": "copilot-opaque-fixture"}, |
| 389 | + block_type="THINKING", |
| 390 | + ) |
| 391 | + separator = DomainEvent(type=DomainEventType.CONTENT_DELTA, data={"text": "Answer"}) |
| 392 | + accumulator = StreamingAccumulator() |
| 393 | + events = [unsigned, separator, signed] if empty_first else [signed, separator, unsigned] |
| 394 | + for event in events: |
| 395 | + accumulator.add(event) |
| 396 | + |
| 397 | + response = accumulator.to_chat_response() |
| 398 | + assert [block.type for block in response.content] == ( |
| 399 | + ["text", "thinking"] if empty_first else ["thinking", "text"] |
| 400 | + ) |
| 401 | + blocks = [block for block in response.content if isinstance(block, ThinkingBlock)] |
| 402 | + assert len(blocks) == 1 |
| 403 | + assert blocks[0].thinking == thinking |
| 404 | + assert blocks[0].signature == "copilot-opaque-fixture" |
| 405 | + assert response.content_blocks is not None |
| 406 | + ui_thinking = [ |
| 407 | + block for block in response.content_blocks if isinstance(block, ThinkingContent) |
| 408 | + ] |
| 409 | + assert [block.text for block in ui_thinking] == ([thinking] if thinking else []) |
| 410 | + |
| 411 | + @pytest.mark.parametrize("object_event", [False, True], ids=["dict", "sdk-object"]) |
| 412 | + @pytest.mark.parametrize("split", [False, True], ids=["single", "split"]) |
| 413 | + @pytest.mark.parametrize( |
| 414 | + ("thinking", "opaque"), |
| 415 | + [ |
| 416 | + ("Visible reasoning", "copilot-opaque-fixture"), |
| 417 | + ("", "copilot-opaque-fixture"), |
| 418 | + ("Visible reasoning", None), |
| 419 | + ("", None), |
| 420 | + ], |
| 421 | + ids=["signed-visible", "opaque-only", "unsigned-visible", "empty"], |
| 422 | + ) |
| 423 | + def test_provenance_survives_capture_and_context_round_trip( |
| 424 | + self, |
| 425 | + object_event: bool, |
| 426 | + split: bool, |
| 427 | + thinking: str, |
| 428 | + opaque: str | None, |
| 429 | + caplog: pytest.LogCaptureFixture, |
| 430 | + ) -> None: |
| 431 | + """Tag producer state without rendering opaque bytes or changing SDK history.""" |
| 432 | + caplog.set_level(logging.DEBUG) |
| 433 | + accumulator = StreamingAccumulator() |
| 434 | + # A signature-only trailing delta must consolidate with visible reasoning. |
| 435 | + chunks = [(thinking, None), ("", opaque)] if split else [(thinking, opaque)] |
| 436 | + config = load_event_config() |
| 437 | + for text, signature in chunks: |
| 438 | + if object_event: |
| 439 | + sdk_event = SimpleNamespace( |
| 440 | + type="assistant.reasoning_delta", |
| 441 | + data=SimpleNamespace(delta_content=text, reasoning_opaque=signature), |
| 442 | + ) |
| 443 | + event = extract_event_fields(sdk_event) |
| 444 | + else: |
| 445 | + event = { |
| 446 | + "type": "assistant.reasoning_delta", |
| 447 | + "data": {"text": text, "reasoning_opaque": signature}, |
| 448 | + } |
| 449 | + translated = translate_event(event, config) |
| 450 | + assert translated is not None |
| 451 | + accumulator.add(translated) |
| 452 | + |
| 453 | + response = accumulator.to_chat_response() |
| 454 | + blocks = [block for block in response.content if isinstance(block, ThinkingBlock)] |
| 455 | + assert len(blocks) == int(bool(thinking or opaque)) |
| 456 | + if blocks: |
| 457 | + assert blocks[0].thinking == thinking |
| 458 | + assert blocks[0].signature == opaque |
| 459 | + assert isinstance(blocks[0], BaseModel) |
| 460 | + assert blocks[0].model_dump()["thinking_provenance"] == { |
| 461 | + "version": 1, |
| 462 | + "provider": "github-copilot", |
| 463 | + } |
| 464 | + assert response.text is None |
| 465 | + if thinking: |
| 466 | + assert response.content_blocks is not None |
| 467 | + assert len(response.content_blocks) == 1 |
| 468 | + ui_block = response.content_blocks[0] |
| 469 | + assert isinstance(ui_block, ThinkingContent) |
| 470 | + assert ui_block.text == thinking |
| 471 | + else: |
| 472 | + assert response.content_blocks is None # Opaque-only state is persistence-only. |
| 473 | + assert "copilot-opaque-fixture" not in caplog.text |
| 474 | + |
| 475 | + # The loop persists response content via model_dump, then rebuilds Messages |
| 476 | + # and ChatRequests. Exercise both Python and JSON reloads without Core edits. |
| 477 | + # Local Core stubs omit Pydantic APIs; retain real runtime validation here. |
| 478 | + response_model: Any = ChatResponse |
| 479 | + request_model: Any = ChatRequest |
| 480 | + assert isinstance(response, BaseModel) |
| 481 | + restored = response_model.model_validate(response.model_dump()) |
| 482 | + restored = response_model.model_validate_json(restored.model_dump_json()) |
| 483 | + assistant = Message(role="assistant", content=restored.content) |
| 484 | + assert isinstance(assistant, BaseModel) |
| 485 | + request = request_model( |
| 486 | + messages=[assistant, Message(role="user", content="Continue")], |
| 487 | + ) |
| 488 | + for _ in range(2): |
| 489 | + request = request_model.model_validate(request.model_dump()) |
| 490 | + request = request_model.model_validate_json(request.model_dump_json()) |
| 491 | + assert request.messages[0].model_dump()["content"] == assistant.model_dump()["content"] |
| 492 | + |
| 493 | + # Copilot history deliberately serializes visible thinking only. Stripping |
| 494 | + # the new provenance must produce the exact same SDK prompt. |
| 495 | + untagged = request.model_dump() |
| 496 | + for block in untagged["messages"][0]["content"]: |
| 497 | + block.pop("thinking_provenance", None) |
| 498 | + prompt = convert_chat_request(request).prompt |
| 499 | + assert prompt == convert_chat_request(request_model.model_validate(untagged)).prompt |
| 500 | + assert "copilot-opaque-fixture" not in prompt |
| 501 | + assert "thinking_provenance" not in prompt |
| 502 | + if thinking: |
| 503 | + assert f"[Thinking: {thinking}]" in prompt |
| 504 | + |
367 | 505 |
|
368 | 506 | # --------------------------------------------------------------------------- |
369 | 507 | # T-4 Contract Test Gaps — EventRouter integration tests |
|
0 commit comments