diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py index fed2d89ff4..f716b5be10 100644 --- a/src/agents/memory/openai_responses_compaction_session.py +++ b/src/agents/memory/openai_responses_compaction_session.py @@ -508,7 +508,19 @@ async def clear_session(self) -> None: async with self._mutation_lock: try: await self.underlying_session.clear_session() - except (Exception, asyncio.CancelledError): + except asyncio.CancelledError: + # Some session backends shield mutations so a cancellation can be + # observed only after the clear has committed. Treat cancellation as + # potentially destructive and forget the response chain as well as + # cached history, so cleared history cannot be reconstructed later. + self._compaction_candidate_items = None + self._session_items = None + self._response_id = None + self._deferred_response_id = None + self._last_unstored_response_id = None + self._mutation_generation += 1 + raise + except Exception: self._compaction_candidate_items = None self._session_items = None self._deferred_response_id = None @@ -516,7 +528,9 @@ async def clear_session(self) -> None: raise self._compaction_candidate_items = [] self._session_items = [] + self._response_id = None self._deferred_response_id = None + self._last_unstored_response_id = None self._mutation_generation += 1 async def _ensure_compaction_candidates( diff --git a/tests/memory/test_openai_responses_compaction_session.py b/tests/memory/test_openai_responses_compaction_session.py index 7f8895a31c..cf13aad57c 100644 --- a/tests/memory/test_openai_responses_compaction_session.py +++ b/tests/memory/test_openai_responses_compaction_session.py @@ -100,6 +100,51 @@ def test_excludes_easy_user_messages_without_type(self) -> None: class TestOpenAIResponsesCompactionSession: + @pytest.mark.asyncio + async def test_clear_session_forgets_response_chain_state(self) -> None: + mock_session = self.create_mock_session() + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock() + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=mock_session, + client=mock_client, + should_trigger_compaction=lambda _context: False, + ) + + await session.run_compaction({"response_id": "resp-old", "store": True}) + await session.clear_session() + + with pytest.raises(ValueError, match="requires a response_id"): + await session.run_compaction({"force": True, "compaction_mode": "previous_response_id"}) + mock_client.responses.compact.assert_not_called() + + @pytest.mark.asyncio + async def test_cancelled_clear_forgets_response_chain_state(self) -> None: + class CancelAfterCommittedClearSession(SimpleListSession): + async def clear_session(self) -> None: + await super().clear_session() + raise asyncio.CancelledError() + + underlying = CancelAfterCommittedClearSession() + mock_client = MagicMock() + mock_client.responses.compact = AsyncMock() + session = OpenAIResponsesCompactionSession( + session_id="test", + underlying_session=underlying, + client=mock_client, + should_trigger_compaction=lambda _context: False, + ) + + await session.run_compaction({"response_id": "resp-old", "store": True}) + with pytest.raises(asyncio.CancelledError): + await session.clear_session() + + assert await underlying.get_items() == [] + with pytest.raises(ValueError, match="requires a response_id"): + await session.run_compaction({"force": True, "compaction_mode": "previous_response_id"}) + mock_client.responses.compact.assert_not_called() + def test_client_preserves_falsy_default_client(self) -> None: mock_client = MagicMock() mock_client.__bool__.return_value = False