Files
openai--openai-agents-python/tests/memory/test_openai_responses_compaction_session.py
T
2026-07-13 12:39:17 +08:00

1360 lines
50 KiB
Python

from __future__ import annotations
import logging
import warnings as warnings_module
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from agents import Agent, Runner
from agents.items import TResponseInputItem
from agents.memory import (
OpenAIResponsesCompactionSession,
Session,
SessionSettings,
is_openai_responses_compaction_aware_session,
)
from agents.memory.openai_responses_compaction_session import (
DEFAULT_COMPACTION_THRESHOLD,
_strip_orphaned_assistant_ids,
is_openai_model_name,
select_compaction_candidate_items,
)
from agents.run_internal.items import (
TOOL_CALL_SESSION_DESCRIPTION_KEY,
TOOL_CALL_SESSION_TITLE_KEY,
)
from tests.fake_model import FakeModel
from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message
from tests.utils.simple_session import SimpleListSession
class TestIsOpenAIModelName:
def test_gpt_models(self) -> None:
assert is_openai_model_name("gpt-4o") is True
assert is_openai_model_name("gpt-4o-mini") is True
assert is_openai_model_name("gpt-3.5-turbo") is True
assert is_openai_model_name("gpt-4.1") is True
assert is_openai_model_name("gpt-5") is True
assert is_openai_model_name("gpt-5.2") is True
assert is_openai_model_name("gpt-5-mini") is True
assert is_openai_model_name("gpt-5-nano") is True
def test_o_models(self) -> None:
assert is_openai_model_name("o1") is True
assert is_openai_model_name("o1-preview") is True
assert is_openai_model_name("o3") is True
def test_fine_tuned_models(self) -> None:
assert is_openai_model_name("ft:gpt-4o-mini:org:proj:suffix") is True
assert is_openai_model_name("ft:gpt-4.1:my-org::id") is True
def test_invalid_models(self) -> None:
assert is_openai_model_name("") is False
assert is_openai_model_name("not-openai") is False
class TestSelectCompactionCandidateItems:
def test_excludes_user_messages(self) -> None:
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "hi"}),
]
result = select_compaction_candidate_items(items)
assert len(result) == 1
assert result[0].get("role") == "assistant"
def test_excludes_compaction_items(self) -> None:
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "compaction", "summary": "..."}),
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "hi"}),
]
result = select_compaction_candidate_items(items)
assert len(result) == 1
assert result[0].get("type") == "message"
def test_excludes_easy_user_messages_without_type(self) -> None:
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"content": "hi", "role": "user"}),
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "hello"}),
]
result = select_compaction_candidate_items(items)
assert len(result) == 1
assert result[0].get("role") == "assistant"
class TestOpenAIResponsesCompactionSession:
def create_mock_session(self) -> MagicMock:
mock = MagicMock(spec=Session)
mock.session_id = "test-session"
mock.get_items = AsyncMock(return_value=[])
mock.add_items = AsyncMock()
mock.pop_item = AsyncMock(return_value=None)
mock.clear_session = AsyncMock()
return mock
def test_init_validates_model(self) -> None:
mock_session = self.create_mock_session()
with pytest.raises(ValueError, match="Unsupported model"):
OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
model="claude-3",
)
def test_init_accepts_valid_model(self) -> None:
mock_session = self.create_mock_session()
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
model="gpt-4.1",
)
assert session.model == "gpt-4.1"
@pytest.mark.asyncio
async def test_add_items_delegates(self) -> None:
mock_session = self.create_mock_session()
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
)
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "test"})
]
await session.add_items(items)
mock_session.add_items.assert_called_once_with(items)
@pytest.mark.asyncio
async def test_get_items_delegates(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = [{"type": "message", "content": "test"}]
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
)
result = await session.get_items()
assert len(result) == 1
mock_session.get_items.assert_called_once()
@pytest.mark.asyncio
async def test_run_compaction_requires_response_id(self) -> None:
mock_session = self.create_mock_session()
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
compaction_mode="previous_response_id",
)
with pytest.raises(ValueError, match="previous_response_id compaction"):
await session.run_compaction()
@pytest.mark.asyncio
async def test_run_compaction_input_mode_without_response_id(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "world"},
),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "assistant",
"content": "compacted",
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session.run_compaction({"force": True})
mock_client.responses.compact.assert_called_once()
call_kwargs = mock_client.responses.compact.call_args.kwargs
assert call_kwargs.get("model") == "gpt-4.1"
assert "previous_response_id" not in call_kwargs
assert call_kwargs.get("input") == items
@pytest.mark.asyncio
async def test_run_compaction_auto_without_response_id_uses_input(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
await session.run_compaction({"force": True})
mock_client.responses.compact.assert_called_once()
call_kwargs = mock_client.responses.compact.call_args.kwargs
assert "previous_response_id" not in call_kwargs
assert call_kwargs.get("input") == items
@pytest.mark.asyncio
async def test_run_compaction_input_mode_strips_internal_tool_call_metadata(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_123",
"name": "lookup_account",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_123",
"output": "ok",
},
),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session.run_compaction({"force": True})
call_kwargs = mock_client.responses.compact.call_args.kwargs
compact_input = cast(list[dict[str, Any]], call_kwargs["input"])
assert compact_input[0]["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in compact_input[0]
assert TOOL_CALL_SESSION_TITLE_KEY not in compact_input[0]
@pytest.mark.asyncio
async def test_run_compaction_uses_sanitized_cached_items_after_add(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = []
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session._ensure_compaction_candidates()
await session.add_items(
[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_cached",
"name": "lookup_account",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_cached",
"output": "ok",
},
),
]
)
await session.run_compaction({"force": True})
call_kwargs = mock_client.responses.compact.call_args.kwargs
compact_input = cast(list[dict[str, Any]], call_kwargs["input"])
assert compact_input[0]["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in compact_input[0]
assert TOOL_CALL_SESSION_TITLE_KEY not in compact_input[0]
@pytest.mark.asyncio
async def test_run_compaction_auto_uses_input_when_store_false(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "world"},
),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="auto",
)
await session.run_compaction({"response_id": "resp-auto", "store": False, "force": True})
mock_client.responses.compact.assert_called_once()
call_kwargs = mock_client.responses.compact.call_args.kwargs
assert call_kwargs.get("model") == "gpt-4.1"
assert "previous_response_id" not in call_kwargs
assert call_kwargs.get("input") == items
@pytest.mark.asyncio
async def test_run_compaction_auto_uses_default_store_when_unset(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "world"},
),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="auto",
)
await session.run_compaction({"response_id": "resp-auto", "store": False, "force": True})
await session.run_compaction({"response_id": "resp-stored", "force": True})
assert mock_client.responses.compact.call_count == 2
first_kwargs = mock_client.responses.compact.call_args_list[0].kwargs
second_kwargs = mock_client.responses.compact.call_args_list[1].kwargs
assert "previous_response_id" not in first_kwargs
assert second_kwargs.get("previous_response_id") == "resp-stored"
assert "input" not in second_kwargs
@pytest.mark.asyncio
async def test_run_compaction_auto_uses_input_when_last_response_unstored(self) -> None:
mock_session = self.create_mock_session()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "world"},
),
]
mock_session.get_items.return_value = items
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "assistant",
"content": "compacted",
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="auto",
)
await session.run_compaction(
{"response_id": "resp-unstored", "store": False, "force": True}
)
await session.run_compaction({"force": True})
assert mock_client.responses.compact.call_count == 2
first_kwargs = mock_client.responses.compact.call_args_list[0].kwargs
second_kwargs = mock_client.responses.compact.call_args_list[1].kwargs
assert "previous_response_id" not in first_kwargs
assert "previous_response_id" not in second_kwargs
assert second_kwargs.get("input") == mock_compact_response.output
@pytest.mark.asyncio
async def test_run_compaction_skips_when_below_threshold(self) -> None:
mock_session = self.create_mock_session()
# Return fewer than threshold items
mock_session.get_items.return_value = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": f"msg{i}"})
for i in range(DEFAULT_COMPACTION_THRESHOLD - 1)
]
mock_client = MagicMock()
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
await session.run_compaction({"response_id": "resp-123"})
# Should not have called the compact API
mock_client.responses.compact.assert_not_called()
@pytest.mark.asyncio
async def test_run_compaction_executes_when_threshold_met(self) -> None:
mock_session = self.create_mock_session()
# Return exactly threshold items (all assistant messages = candidates)
mock_session.get_items.return_value = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": f"msg{i}"})
for i in range(DEFAULT_COMPACTION_THRESHOLD)
]
mock_compact_response = MagicMock()
mock_compact_response.output = [{"type": "compaction", "summary": "compacted"}]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
model="gpt-4.1",
)
await session.run_compaction({"response_id": "resp-123"})
mock_client.responses.compact.assert_called_once_with(
previous_response_id="resp-123",
model="gpt-4.1",
)
mock_session.clear_session.assert_called_once()
mock_session.add_items.assert_called()
@pytest.mark.asyncio
async def test_run_compaction_restores_history_when_replacement_add_fails(self) -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_123",
"name": "lookup",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup private records.",
},
),
]
compacted_items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "compacted"},
)
]
class PartiallyFailingReplacementSession(SimpleListSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
super().__init__(history=history)
self.add_calls = 0
self.clear_calls = 0
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.add_calls += 1
if self.add_calls == 1:
await super().add_items(items[:1])
raise RuntimeError("replacement failed")
await super().add_items(items)
async def clear_session(self) -> None:
self.clear_calls += 1
await super().clear_session()
failing_session = PartiallyFailingReplacementSession(history=history)
mock_compact_response = MagicMock()
mock_compact_response.output = compacted_items
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=failing_session,
client=mock_client,
compaction_mode="input",
)
with pytest.raises(RuntimeError, match="replacement failed"):
await session.run_compaction({"force": True})
assert await failing_session.get_items() == history
assert failing_session.clear_calls == 2
assert failing_session.add_calls == 2
@pytest.mark.asyncio
async def test_run_compaction_restores_full_history_when_session_limit_applies(
self,
) -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "oldest"}),
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "middle"}),
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "newest"}),
]
compacted_items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "compacted"},
)
]
class LimitedFailingReplacementSession(SimpleListSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
super().__init__(history=history)
self.session_settings = SessionSettings(limit=1)
self.add_calls = 0
self.clear_calls = 0
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
if limit is None and self.session_settings is not None:
limit = self.session_settings.limit
return await super().get_items(limit)
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.add_calls += 1
if self.add_calls == 1:
await super().add_items(items[:1])
raise RuntimeError("replacement failed")
await super().add_items(items)
async def clear_session(self) -> None:
self.clear_calls += 1
await super().clear_session()
failing_session = LimitedFailingReplacementSession(history=history)
mock_compact_response = MagicMock()
mock_compact_response.output = compacted_items
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=failing_session,
client=mock_client,
compaction_mode="input",
)
with pytest.raises(RuntimeError, match="replacement failed"):
await session.run_compaction({"force": True})
assert await failing_session.get_items(limit=10) == history
assert failing_session.clear_calls == 2
assert failing_session.add_calls == 2
@pytest.mark.asyncio
async def test_run_compaction_does_not_restore_when_clear_fails_without_mutation(
self,
) -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}),
]
compacted_items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "compacted"},
)
]
class FailingClearBeforeMutationSession(SimpleListSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
super().__init__(history=history)
self.add_calls = 0
self.clear_calls = 0
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.add_calls += 1
await super().add_items(items)
async def clear_session(self) -> None:
self.clear_calls += 1
raise RuntimeError("clear failed")
failing_session = FailingClearBeforeMutationSession(history=history)
mock_compact_response = MagicMock()
mock_compact_response.output = compacted_items
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=failing_session,
client=mock_client,
compaction_mode="input",
)
with pytest.raises(RuntimeError, match="clear failed"):
await session.run_compaction({"force": True})
assert await failing_session.get_items() == history
assert failing_session.clear_calls == 1
assert failing_session.add_calls == 0
@pytest.mark.asyncio
async def test_run_compaction_restores_history_when_clear_fails_after_mutation(
self,
) -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}),
]
compacted_items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "compacted"},
)
]
class PartiallyFailingClearSession(SimpleListSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
super().__init__(history=history)
self.add_calls = 0
self.clear_calls = 0
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.add_calls += 1
await super().add_items(items)
async def clear_session(self) -> None:
self.clear_calls += 1
await super().clear_session()
raise RuntimeError("clear failed")
failing_session = PartiallyFailingClearSession(history=history)
mock_compact_response = MagicMock()
mock_compact_response.output = compacted_items
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=failing_session,
client=mock_client,
compaction_mode="input",
)
with pytest.raises(RuntimeError, match="clear failed"):
await session.run_compaction({"force": True})
assert await failing_session.get_items() == history
assert failing_session.clear_calls == 1
assert failing_session.add_calls == 1
@pytest.mark.asyncio
async def test_run_compaction_reraises_replacement_error_when_restore_fails(
self, caplog: pytest.LogCaptureFixture
) -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "original"}),
]
compacted_items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "compacted"},
)
]
class FailingRestoreSession(SimpleListSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
super().__init__(history=history)
self.add_calls = 0
self.clear_calls = 0
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.add_calls += 1
if self.add_calls == 1:
await super().add_items(items[:1])
raise RuntimeError("replacement failed")
raise RuntimeError("restore failed")
async def clear_session(self) -> None:
self.clear_calls += 1
await super().clear_session()
failing_session = FailingRestoreSession(history=history)
mock_compact_response = MagicMock()
mock_compact_response.output = compacted_items
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=failing_session,
client=mock_client,
compaction_mode="input",
)
with caplog.at_level(logging.WARNING, logger="openai-agents.openai.compaction"):
with pytest.raises(RuntimeError, match="replacement failed"):
await session.run_compaction({"force": True})
assert (
"Failed to restore session history after compaction replacement failed." in caplog.text
)
assert failing_session.clear_calls == 2
assert failing_session.add_calls == 2
@pytest.mark.asyncio
async def test_run_compaction_force_bypasses_threshold(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = []
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
await session.run_compaction({"response_id": "resp-123", "force": True})
mock_client.responses.compact.assert_called_once()
@pytest.mark.asyncio
async def test_run_compaction_suppresses_model_dump_warnings(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": "hi"})
for _ in range(DEFAULT_COMPACTION_THRESHOLD)
]
class WarningModel:
def __init__(self) -> None:
self.received_warnings_arg: bool | None = None
def model_dump(
self, *, exclude_unset: bool, warnings: bool | None = None
) -> dict[str, Any]:
self.received_warnings_arg = warnings
if warnings:
warnings_module.warn("unexpected warning", stacklevel=2)
return {"type": "message", "role": "assistant", "content": "ok"}
warning_model = WarningModel()
mock_compact_response = MagicMock()
mock_compact_response.output = [warning_model]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
with warnings_module.catch_warnings():
warnings_module.simplefilter("error")
await session.run_compaction({"response_id": "resp-123"})
assert warning_model.received_warnings_arg is False
mock_client.responses.compact.assert_called_once_with(
previous_response_id="resp-123",
model="gpt-4.1",
)
@pytest.mark.asyncio
async def test_run_compaction_normalizes_compacted_user_image_messages(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = []
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_image",
"image_url": "https://example.com/image.png",
"file_id": None,
"detail": "auto",
},
],
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session.run_compaction({"force": True, "compaction_mode": "input"})
stored_items = mock_session.add_items.call_args[0][0]
assert stored_items == [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_image",
"image_url": "https://example.com/image.png",
"detail": "auto",
},
],
}
]
@pytest.mark.asyncio
async def test_run_compaction_normalizes_compacted_user_file_messages(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = []
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_file",
"file_url": "https://example.com/report.pdf",
"file_id": None,
"filename": "report.pdf",
"detail": "high",
},
],
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session.run_compaction({"force": True, "compaction_mode": "input"})
stored_items = mock_session.add_items.call_args[0][0]
assert stored_items == [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_file",
"file_url": "https://example.com/report.pdf",
"filename": "report.pdf",
"detail": "high",
},
],
}
]
@pytest.mark.asyncio
async def test_run_compaction_normalizes_file_id_inputs_and_preserves_metadata(self) -> None:
mock_session = self.create_mock_session()
mock_session.get_items.return_value = []
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_file",
"file_id": "file_123",
"file_url": None,
"filename": "report.pdf",
"detail": "low",
},
],
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
compaction_mode="input",
)
await session.run_compaction({"force": True, "compaction_mode": "input"})
stored_items = mock_session.add_items.call_args[0][0]
assert stored_items == [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "analyze this input"},
{
"type": "input_file",
"file_id": "file_123",
"filename": "report.pdf",
"detail": "low",
},
],
}
]
@pytest.mark.asyncio
async def test_run_compaction_preserves_history_when_output_normalization_fails(self) -> None:
history = [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "world"}],
},
]
underlying = SimpleListSession(history=cast(list[TResponseInputItem], history))
mock_compact_response = MagicMock()
mock_compact_response.output = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "hello"},
{"type": "input_image", "detail": "auto"},
],
}
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode="input",
)
with pytest.raises(
ValueError, match="Compaction input_image item missing image_url or file_id."
):
await session.run_compaction({"force": True, "compaction_mode": "input"})
assert await session.get_items() == history
@pytest.mark.asyncio
async def test_compaction_runs_during_runner_flow(self) -> None:
"""Ensure Runner triggers compaction when using a compaction-aware session."""
underlying = SimpleListSession()
compacted = SimpleNamespace(
output=[{"type": "compaction", "encrypted_content": "enc"}],
)
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=compacted)
session = OpenAIResponsesCompactionSession(
session_id="demo",
underlying_session=underlying,
client=mock_client,
should_trigger_compaction=lambda ctx: True,
)
model = FakeModel(initial_output=[get_text_message("ok")])
agent = Agent(name="assistant", model=model)
await Runner.run(agent, "hello", session=session)
mock_client.responses.compact.assert_awaited_once()
items = await session.get_items()
assert any(isinstance(item, dict) and item.get("type") == "compaction" for item in items)
@pytest.mark.asyncio
async def test_compaction_skips_when_tool_outputs_present(self) -> None:
underlying = SimpleListSession()
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock()
session = OpenAIResponsesCompactionSession(
session_id="demo",
underlying_session=underlying,
client=mock_client,
should_trigger_compaction=lambda ctx: True,
)
tool = get_function_tool(name="do_thing", return_value="done")
model = FakeModel(initial_output=[get_function_tool_call("do_thing")])
agent = Agent(
name="assistant",
model=model,
tools=[tool],
tool_use_behavior="stop_on_first_tool",
)
await Runner.run(agent, "hello", session=session)
mock_client.responses.compact.assert_not_called()
@pytest.mark.asyncio
async def test_deferred_compaction_includes_compaction_mode_in_context(self) -> None:
underlying = SimpleListSession()
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock()
observed = {}
def should_trigger_compaction(context: dict[str, Any]) -> bool:
observed["mode"] = context["compaction_mode"]
return False
session = OpenAIResponsesCompactionSession(
session_id="demo",
underlying_session=underlying,
client=mock_client,
compaction_mode="input",
should_trigger_compaction=should_trigger_compaction,
)
tool = get_function_tool(name="do_thing", return_value="done")
model = FakeModel(initial_output=[get_function_tool_call("do_thing")])
agent = Agent(
name="assistant",
model=model,
tools=[tool],
tool_use_behavior="stop_on_first_tool",
)
await Runner.run(agent, "hello", session=session)
assert observed["mode"] == "input"
mock_client.responses.compact.assert_not_called()
@pytest.mark.asyncio
async def test_compaction_runs_after_deferred_tool_outputs_when_due(self) -> None:
underlying = SimpleListSession()
compacted = SimpleNamespace(
output=[{"type": "compaction", "summary": "compacted"}],
)
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=compacted)
def should_trigger_compaction(context: dict[str, Any]) -> bool:
return any(
isinstance(item, dict) and item.get("type") == "function_call_output"
for item in context["session_items"]
)
session = OpenAIResponsesCompactionSession(
session_id="demo",
underlying_session=underlying,
client=mock_client,
should_trigger_compaction=should_trigger_compaction,
)
tool = get_function_tool(name="do_thing", return_value="done")
model = FakeModel()
model.add_multiple_turn_outputs(
[
[get_function_tool_call("do_thing")],
[get_text_message("ok")],
]
)
agent = Agent(
name="assistant",
model=model,
tools=[tool],
tool_use_behavior="stop_on_first_tool",
)
await Runner.run(agent, "hello", session=session)
await Runner.run(agent, "followup", session=session)
mock_client.responses.compact.assert_awaited_once()
@pytest.mark.asyncio
async def test_deferred_compaction_persists_across_tool_turns(self) -> None:
underlying = SimpleListSession()
compacted = SimpleNamespace(
output=[{"type": "compaction", "summary": "compacted"}],
)
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=compacted)
should_compact_calls = {"count": 0}
def should_trigger_compaction(context: dict[str, Any]) -> bool:
should_compact_calls["count"] += 1
return should_compact_calls["count"] == 1
session = OpenAIResponsesCompactionSession(
session_id="demo",
underlying_session=underlying,
client=mock_client,
should_trigger_compaction=should_trigger_compaction,
)
tool = get_function_tool(name="do_thing", return_value="done")
model = FakeModel()
model.add_multiple_turn_outputs(
[
[get_function_tool_call("do_thing")],
[get_function_tool_call("do_thing")],
[get_text_message("ok")],
]
)
agent = Agent(
name="assistant",
model=model,
tools=[tool],
tool_use_behavior="stop_on_first_tool",
)
await Runner.run(agent, "hello", session=session)
await Runner.run(agent, "again", session=session)
await Runner.run(agent, "final", session=session)
mock_client.responses.compact.assert_awaited_once()
class TestStripOrphanedAssistantIds:
def test_noop_when_empty(self) -> None:
assert _strip_orphaned_assistant_ids([]) == []
def test_strips_id_from_assistant_when_no_reasoning(self) -> None:
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "id": "msg_abc", "content": "hi"},
),
cast(
TResponseInputItem,
{"type": "message", "role": "user", "content": "hello"},
),
]
result = _strip_orphaned_assistant_ids(items)
assert "id" not in result[0]
# user message untouched
assert result[1] == items[1]
def test_preserves_id_when_reasoning_present(self) -> None:
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_123", "content": "..."}),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "id": "msg_abc", "content": "hi"},
),
]
result = _strip_orphaned_assistant_ids(items)
assert result[1].get("id") == "msg_abc"
def test_preserves_assistant_without_id(self) -> None:
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "content": "hi"},
),
]
result = _strip_orphaned_assistant_ids(items)
assert result == items
def test_strips_multiple_assistant_ids(self) -> None:
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "id": "msg_1", "content": "a"},
),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "id": "msg_2", "content": "b"},
),
cast(
TResponseInputItem,
{"type": "message", "role": "assistant", "id": "msg_3", "content": "c"},
),
]
result = _strip_orphaned_assistant_ids(items)
for item in result:
assert "id" not in item
class TestCompactionStripsOrphanedIds:
"""Regression test for #2727: gpt-5.4 compact retains assistant msg IDs after
stripping reasoning items, causing 400 errors on the next responses.create call."""
def create_mock_session(self) -> MagicMock:
mock = MagicMock(spec=Session)
mock.session_id = "test-session"
mock.get_items = AsyncMock(return_value=[])
mock.add_items = AsyncMock()
mock.pop_item = AsyncMock(return_value=None)
mock.clear_session = AsyncMock()
return mock
@pytest.mark.asyncio
async def test_run_compaction_strips_orphaned_assistant_ids(self) -> None:
"""Compacted output with assistant IDs but no reasoning items should
have those IDs removed before being stored."""
mock_session = self.create_mock_session()
mock_session.get_items.return_value = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": f"m{i}"})
for i in range(DEFAULT_COMPACTION_THRESHOLD)
]
# Simulate gpt-5.4 compact output: assistant msgs WITH ids, NO reasoning items
mock_compact_response = MagicMock()
mock_compact_response.output = [
{"type": "message", "role": "assistant", "id": "msg_aaa", "content": "summary 1"},
{"type": "message", "role": "assistant", "id": "msg_bbb", "content": "summary 2"},
{"type": "message", "role": "assistant", "id": "msg_ccc", "content": "summary 3"},
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
await session.run_compaction({"response_id": "resp-123"})
# Verify stored items have no orphaned ids
stored_items = mock_session.add_items.call_args[0][0]
for item in stored_items:
assert "id" not in item, f"orphaned id not stripped: {item}"
@pytest.mark.asyncio
async def test_run_compaction_keeps_ids_when_reasoning_present(self) -> None:
"""When compact output includes reasoning items, assistant IDs should be kept."""
mock_session = self.create_mock_session()
mock_session.get_items.return_value = [
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": f"m{i}"})
for i in range(DEFAULT_COMPACTION_THRESHOLD)
]
mock_compact_response = MagicMock()
mock_compact_response.output = [
{"type": "reasoning", "id": "rs_111", "content": "thinking..."},
{"type": "message", "role": "assistant", "id": "msg_aaa", "content": "answer"},
]
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_session,
client=mock_client,
)
await session.run_compaction({"response_id": "resp-123"})
stored_items = mock_session.add_items.call_args[0][0]
assistant_items = [i for i in stored_items if i.get("role") == "assistant"]
assert assistant_items[0]["id"] == "msg_aaa"
class TestTypeGuard:
def test_is_compaction_aware_session_true(self) -> None:
mock_underlying = MagicMock(spec=Session)
mock_underlying.session_id = "test"
mock_underlying.get_items = AsyncMock(return_value=[])
mock_underlying.add_items = AsyncMock()
mock_underlying.pop_item = AsyncMock(return_value=None)
mock_underlying.clear_session = AsyncMock()
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=mock_underlying,
)
assert is_openai_responses_compaction_aware_session(session) is True
def test_is_compaction_aware_session_false(self) -> None:
mock_session = MagicMock(spec=Session)
assert is_openai_responses_compaction_aware_session(mock_session) is False
def test_is_compaction_aware_session_none(self) -> None:
assert is_openai_responses_compaction_aware_session(None) is False