-
Notifications
You must be signed in to change notification settings - Fork 101
LCORE-1574: Integration tests for conversation compaction #2427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,14 +1,18 @@ | ||||||||||
| """Shared fixtures for integration tests.""" | ||||||||||
|
|
||||||||||
| # pylint: disable=too-many-lines | ||||||||||
|
|
||||||||||
| import importlib | ||||||||||
| import os | ||||||||||
| from collections import defaultdict | ||||||||||
| from collections.abc import AsyncIterator, Generator | ||||||||||
| from pathlib import Path | ||||||||||
| from typing import Any, Optional | ||||||||||
|
|
||||||||||
| import pytest | ||||||||||
| from fastapi import Request, Response | ||||||||||
| from fastapi.testclient import TestClient | ||||||||||
| from ogx_client import OpenAIResponseMessage | ||||||||||
| from ogx_client.models.list_models_v1_models_get200_response import ( | ||||||||||
| ListModelsV1ModelsGet200Response, | ||||||||||
| ) | ||||||||||
|
|
@@ -457,6 +461,7 @@ def mock_agent_run_stream(events: list[Any]) -> Any: | |||||||||
| """Build an async context manager that yields pydantic-ai stream events.""" | ||||||||||
|
|
||||||||||
| async def _event_stream() -> AsyncIterator[Any]: | ||||||||||
| """Yield pre-built events one at a time.""" | ||||||||||
| for event in events: | ||||||||||
| yield event | ||||||||||
|
|
||||||||||
|
|
@@ -578,6 +583,99 @@ def shutdown_integration_otel_provider(provider: TracerProvider) -> None: | |||||||||
| trace._TRACER_PROVIDER = None # pylint: disable=protected-access | ||||||||||
|
|
||||||||||
|
|
||||||||||
| # ========================================== | ||||||||||
| # In-Memory Conversation Store | ||||||||||
| # ========================================== | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class InMemoryConversationStore: | ||||||||||
| """In-memory fake for the Llama Stack conversations.items API. | ||||||||||
|
|
||||||||||
| Provides stateful ``list`` and ``create`` methods that can be wired onto a | ||||||||||
| mock ``AsyncOgxClient`` so that ``get_all_conversation_items`` and | ||||||||||
| ``_write_summary_marker`` (and ``append_turn_items_to_conversation``) work | ||||||||||
| without patching. | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The docstring says the store exists so that That duplicates the construction at |
||||||||||
| """ | ||||||||||
|
|
||||||||||
| def __init__(self) -> None: | ||||||||||
| """Initialize an empty in-memory conversation store.""" | ||||||||||
| self._store: dict[str, list[OpenAIResponseMessage]] = defaultdict(list) | ||||||||||
|
|
||||||||||
| @property | ||||||||||
| def store(self) -> dict[str, list[OpenAIResponseMessage]]: | ||||||||||
| """Direct access to the backing store for seeding data in tests.""" | ||||||||||
| return self._store | ||||||||||
|
|
||||||||||
| def _dict_to_message(self, raw: dict[str, Any]) -> OpenAIResponseMessage: | ||||||||||
| """Convert a raw item dict (as sent by production code) to a typed object.""" | ||||||||||
| content = raw.get("content", "") | ||||||||||
| if isinstance(content, list): | ||||||||||
| content = " ".join( | ||||||||||
| part.get("text", "") for part in content if isinstance(part, dict) | ||||||||||
| ) | ||||||||||
| role = raw.get("role", "user") | ||||||||||
| return OpenAIResponseMessage.model_construct( | ||||||||||
| type="message", | ||||||||||
| role=role, | ||||||||||
| content=content, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| async def create( | ||||||||||
| self, | ||||||||||
| conversation_id: str, | ||||||||||
| *, | ||||||||||
| items: Any = None, | ||||||||||
| add_items_request: Any = None, | ||||||||||
| **_kwargs: Any, | ||||||||||
| ) -> Any: | ||||||||||
| """Async fake for ``client.conversations.items.create`` and ``client.items.create``.""" | ||||||||||
| if items is None and add_items_request is not None: | ||||||||||
| items = add_items_request.items | ||||||||||
| if items is None: | ||||||||||
| return | ||||||||||
| for raw in items: | ||||||||||
| if isinstance(raw, dict): | ||||||||||
| self._store[conversation_id].append(self._dict_to_message(raw)) | ||||||||||
| else: | ||||||||||
| self._store[conversation_id].append(raw) | ||||||||||
|
|
||||||||||
| def list(self, conversation_id: str, **_kwargs: Any) -> "_FakePaginator": | ||||||||||
| """Fake for ``client.conversations.items.list`` (returns an awaitable).""" | ||||||||||
| return _FakePaginator(list(self._store.get(conversation_id, []))) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class _FakePage: | ||||||||||
| """Single-page result matching the ``AsyncOpenAICursorPage`` protocol.""" | ||||||||||
|
|
||||||||||
| def __init__(self, data: list[OpenAIResponseMessage]) -> None: | ||||||||||
| self.data = data | ||||||||||
| self.has_more = False | ||||||||||
| self.last_id: Optional[str] = None | ||||||||||
|
|
||||||||||
| def has_next_page(self) -> bool: | ||||||||||
| """Return False — the fake always returns all items in one page.""" | ||||||||||
| return False | ||||||||||
|
|
||||||||||
| async def get_next_page(self) -> "_FakePage": | ||||||||||
| """Return an empty page (should never be called).""" | ||||||||||
| return _FakePage([]) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class _FakePaginator: # pylint: disable=too-few-public-methods | ||||||||||
| """Awaitable object matching the ``AsyncPaginator`` protocol.""" | ||||||||||
|
|
||||||||||
| def __init__(self, data: list[OpenAIResponseMessage]) -> None: | ||||||||||
| self._page = _FakePage(data) | ||||||||||
|
|
||||||||||
| def __await__(self) -> Any: | ||||||||||
| """Allow ``await paginator`` to return the page.""" | ||||||||||
| return self._resolve().__await__() # pylint: disable=no-member | ||||||||||
|
|
||||||||||
| async def _resolve(self) -> _FakePage: | ||||||||||
| """Return the single pre-built page.""" | ||||||||||
| return self._page | ||||||||||
|
|
||||||||||
|
|
||||||||||
| # ========================================== | ||||||||||
| # Fixtures | ||||||||||
| # ========================================== | ||||||||||
|
|
@@ -894,9 +992,11 @@ def mock_ogx_client_fixture( | |||||||||
| defaults for integration tests. Individual tests can override specific | ||||||||||
| behaviors as needed. | ||||||||||
|
|
||||||||||
| Patches AsyncOgxClientHolder in both app.endpoints.query and app.main | ||||||||||
| to ensure the mock is active during TestClient startup (when app.main imports | ||||||||||
| and initializes the client) and during endpoint execution. | ||||||||||
| Patches AsyncOgxClientHolder in app.endpoints.query, app.main, | ||||||||||
| app.endpoints.a2a, app.endpoints.responses, utils.endpoints, and | ||||||||||
| app.endpoints.streaming_query to ensure the mock is active during | ||||||||||
| TestClient startup (when app.main imports and initializes the | ||||||||||
| client) and during endpoint execution. | ||||||||||
|
|
||||||||||
| Args: | ||||||||||
| mocker: pytest-mock fixture used to create and patch mocks. | ||||||||||
|
|
@@ -912,6 +1012,12 @@ def mock_ogx_client_fixture( | |||||||||
| mocker.patch( | ||||||||||
| "app.endpoints.conversations_v1.AsyncOgxClientHolder", mock_holder_class | ||||||||||
| ) | ||||||||||
| mocker.patch("app.endpoints.a2a.AsyncOgxClientHolder", mock_holder_class) | ||||||||||
| mocker.patch("app.endpoints.responses.AsyncOgxClientHolder", mock_holder_class) | ||||||||||
| mocker.patch("utils.endpoints.AsyncOgxClientHolder", mock_holder_class) | ||||||||||
| mocker.patch( | ||||||||||
| "app.endpoints.streaming_query.AsyncOgxClientHolder", mock_holder_class | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| mock_client = mocker.AsyncMock() | ||||||||||
|
|
||||||||||
|
|
@@ -946,6 +1052,31 @@ def mock_ogx_client_fixture( | |||||||||
| yield mock_client | ||||||||||
|
|
||||||||||
|
|
||||||||||
| @pytest.fixture(name="mock_conversation_store") | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will do! |
||||||||||
| def conversation_store_fixture( | ||||||||||
| mock_ogx_client: Any, | ||||||||||
| ) -> InMemoryConversationStore: | ||||||||||
| """Wire an in-memory conversation store into the mock Llama Stack client. | ||||||||||
|
|
||||||||||
| Replaces ``conversations.items.list`` and ``conversations.items.create`` | ||||||||||
| on the mock client with stateful fakes so that conversation items persist | ||||||||||
| across calls within a single test. Use ``await store.create(conv_id, items=...)`` | ||||||||||
| to pre-populate a conversation. | ||||||||||
|
|
||||||||||
| Args: | ||||||||||
| mock_ogx_client: The mocked Llama Stack client from mock_ogx_client_fixture. | ||||||||||
|
Comment on lines
+1066
to
+1067
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Use Replace Proposed fix- Args:
+ Parameters:Based on learnings: function argument docstrings must use 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||
|
|
||||||||||
| Returns: | ||||||||||
| The InMemoryConversationStore instance backing the mock client. | ||||||||||
| """ | ||||||||||
| store = InMemoryConversationStore() | ||||||||||
| mock_ogx_client.conversations.items.list = store.list | ||||||||||
| mock_ogx_client.conversations.items.create = store.create | ||||||||||
| mock_ogx_client.items.list = store.list | ||||||||||
| mock_ogx_client.items.create = store.create | ||||||||||
| return store | ||||||||||
|
|
||||||||||
|
|
||||||||||
| @pytest.fixture(name="mock_query_agent") | ||||||||||
| def mock_query_agent_fixture(mocker: MockerFixture) -> Any: | ||||||||||
| """Patch build_agent for /query and return the mock agent.""" | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a blank line after the fixture heading.
markdownlintreports MD022 at Line 82 because the paragraph starts immediately after the heading. Insert one blank line after#### \mock_conversation_store` (function-scoped)`.Proposed fix
#### `mock_conversation_store` (function-scoped) + Wires an `InMemoryConversationStore` into the mock OGX client...📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 82-82: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Source: Linters/SAST tools