chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal, TypeVar, cast
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputText,
)
from agents import Agent
from agents._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key
from agents.items import ToolApprovalItem
from agents.run_context import RunContextWrapper
from agents.run_state import RunState
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
TContext = TypeVar("TContext")
_AUTO_LOOKUP_KEY = object()
class TestSessionState(SandboxSessionState):
"""Concrete ``SandboxSessionState`` subclass for tests that don't need a real backend."""
__test__ = False
type: Literal["test"] = "test"
def make_tool_call(
call_id: str = "call_1",
*,
name: str = "test_tool",
namespace: str | None = None,
status: Literal["in_progress", "completed", "incomplete"] | None = "completed",
arguments: str = "{}",
call_type: Literal["function_call"] = "function_call",
) -> ResponseFunctionToolCall:
"""Build a ResponseFunctionToolCall with common defaults."""
kwargs: dict[str, Any] = {
"type": call_type,
"name": name,
"call_id": call_id,
"status": status,
"arguments": arguments,
}
if namespace is not None:
kwargs["namespace"] = namespace
return ResponseFunctionToolCall(**kwargs)
def make_tool_approval_item(
agent: Agent[Any],
*,
call_id: str = "call_1",
name: str = "test_tool",
namespace: str | None = None,
allow_bare_name_alias: bool = False,
status: Literal["in_progress", "completed", "incomplete"] | None = "completed",
arguments: str = "{}",
tool_lookup_key: FunctionToolLookupKey | None | object = _AUTO_LOOKUP_KEY,
) -> ToolApprovalItem:
"""Create a ToolApprovalItem backed by a function call."""
resolved_tool_lookup_key: FunctionToolLookupKey | None
if tool_lookup_key is _AUTO_LOOKUP_KEY:
resolved_tool_lookup_key = get_function_tool_lookup_key(name, namespace)
else:
resolved_tool_lookup_key = cast(FunctionToolLookupKey | None, tool_lookup_key)
return ToolApprovalItem(
agent=agent,
raw_item=make_tool_call(
call_id=call_id,
name=name,
namespace=namespace,
status=status,
arguments=arguments,
),
tool_namespace=namespace,
tool_lookup_key=resolved_tool_lookup_key,
_allow_bare_name_alias=allow_bare_name_alias,
)
def make_message_output(
*,
message_id: str = "msg_1",
text: str = "Hello",
role: Literal["assistant"] = "assistant",
status: Literal["in_progress", "completed", "incomplete"] = "completed",
) -> ResponseOutputMessage:
"""Create a minimal ResponseOutputMessage."""
return ResponseOutputMessage(
id=message_id,
type="message",
role=role,
status=status,
content=[ResponseOutputText(type="output_text", text=text, annotations=[], logprobs=[])],
)
def make_run_state(
agent: Agent[Any],
*,
context: RunContextWrapper[TContext] | dict[str, Any] | None = None,
original_input: Any = "input",
max_turns: int | None = 3,
) -> RunState[TContext, Agent[Any]]:
"""Create a RunState with sensible defaults for tests."""
wrapper: RunContextWrapper[TContext]
if isinstance(context, RunContextWrapper):
wrapper = context
else:
wrapper = RunContextWrapper(context=context or {}) # type: ignore[arg-type]
return RunState(
context=wrapper,
original_input=original_input,
starting_agent=agent,
max_turns=max_turns,
)
async def roundtrip_state(
agent: Agent[Any],
state: RunState[TContext, Agent[Any]],
mutate_json: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
) -> RunState[TContext, Agent[Any]]:
"""Serialize and restore a RunState, optionally mutating the JSON in between."""
json_data = state.to_json()
if mutate_json is not None:
json_data = mutate_json(json_data)
return await RunState.from_json(agent, json_data)
+493
View File
@@ -0,0 +1,493 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Any, cast
from openai.types.responses import ResponseFunctionToolCall
from agents import Agent, Runner, RunResult, RunResultStreaming
from agents.items import ToolApprovalItem, ToolCallOutputItem, TResponseOutputItem
from agents.run_context import RunContextWrapper
from agents.run_internal.run_loop import NextStepInterruption, SingleStepResult
from agents.run_state import RunState as RunStateClass
from ..fake_model import FakeModel
HITL_REJECTION_MSG = "Tool execution was not approved."
@dataclass
class ApprovalScenario:
"""Container for approval-driven tool scenarios."""
tool: Any
raw_call: TResponseOutputItem
final_output: TResponseOutputItem
assert_result: Callable[[RunResult], None]
@dataclass
class PendingScenario:
"""Container for scenarios with pending approvals."""
tool: Any
raw_call: TResponseOutputItem
assert_result: Callable[[RunResult], None] | None = None
async def roundtrip_interruptions_via_run(
agent: Agent[Any],
model: FakeModel,
raw_call: Any,
*,
user_input: str = "test",
) -> list[ToolApprovalItem]:
"""Run once with a tool call, serialize state, and deserialize it."""
model.set_next_output([raw_call])
result = await Runner.run(agent, user_input)
assert result.interruptions, "expected an interruption"
state = result.to_state()
deserialized_state = await RunStateClass.from_json(agent, state.to_json())
return deserialized_state.get_interruptions()
async def assert_roundtrip_tool_name(
agent: Agent[Any],
model: FakeModel,
raw_call: TResponseOutputItem,
expected_tool_name: str,
*,
user_input: str,
) -> None:
"""Assert that deserialized interruptions keep the tool name intact."""
interruptions = await roundtrip_interruptions_via_run(
agent, model, raw_call, user_input=user_input
)
assert interruptions, "Interruptions should be preserved after deserialization"
assert interruptions[0].tool_name == expected_tool_name, (
f"{expected_tool_name} tool approval should be preserved, not converted to function"
)
def make_state_with_interruptions(
agent: Agent[Any],
interruptions: list[ToolApprovalItem],
*,
original_input: str = "test",
max_turns: int = 10,
) -> RunStateClass[Any, Agent[Any]]:
"""Create a RunState primed with interruptions."""
context = make_context_wrapper()
state = RunStateClass(
context=context,
original_input=original_input,
starting_agent=agent,
max_turns=max_turns,
)
state._current_step = NextStepInterruption(interruptions=interruptions)
return state
async def assert_tool_output_roundtrip(
agent: Agent[Any],
raw_output: Any,
expected_type: str,
*,
output: Any = "command output",
) -> None:
"""Ensure tool outputs keep their type through serialization and deserialization."""
context = make_context_wrapper()
state = RunStateClass(context=context, original_input="test", starting_agent=agent, max_turns=3)
state._generated_items = [
ToolCallOutputItem(
agent=agent,
raw_item=raw_output,
output=output,
)
]
json_data = state.to_json()
generated_items_json = json_data.get("generated_items", [])
assert len(generated_items_json) == 1, f"{expected_type} item should be serialized"
serialized_type = generated_items_json[0].get("raw_item", {}).get("type")
assert serialized_type == expected_type, (
f"Expected {expected_type} in serialized JSON, but got {serialized_type}. "
"Serialization should not coerce tool outputs."
)
deserialized_state = await RunStateClass.from_json(agent, json_data)
assert len(deserialized_state._generated_items) == 1, (
f"{expected_type} item should be deserialized."
)
deserialized_item = deserialized_state._generated_items[0]
assert isinstance(deserialized_item, ToolCallOutputItem)
raw_item = deserialized_item.raw_item
output_type = raw_item.get("type") if isinstance(raw_item, dict) else raw_item.type
assert output_type == expected_type, (
f"Expected {expected_type}, but got {output_type}. "
"Serialization should preserve the tool output type."
)
async def run_and_resume(
agent: Agent[Any],
model: Any,
raw_call: Any,
*,
user_input: str,
) -> RunResult:
"""Run once, then resume from the produced state."""
model.set_next_output([raw_call])
first = await Runner.run(agent, user_input)
return await Runner.run(agent, first.to_state())
def approve_first_interruption(
result: Any,
*,
always_approve: bool = False,
) -> RunStateClass[Any, Agent[Any]]:
"""Approve the first interruption on the result and return the updated state."""
assert getattr(result, "interruptions", None), "expected an approval interruption"
state = cast(RunStateClass[Any, Agent[Any]], result.to_state())
state.approve(result.interruptions[0], always_approve=always_approve)
return state
async def resume_after_first_approval(
agent: Agent[Any],
result: Any,
*,
always_approve: bool = False,
) -> RunResult:
"""Approve the first interruption and resume the run."""
state = approve_first_interruption(result, always_approve=always_approve)
return await Runner.run(agent, state)
async def resume_streamed_after_first_approval(
agent: Agent[Any],
result: Any,
*,
always_approve: bool = False,
) -> RunResultStreaming:
"""Approve the first interruption and resume a streamed run to completion."""
state = approve_first_interruption(result, always_approve=always_approve)
resumed = Runner.run_streamed(agent, state)
await consume_stream(resumed)
return resumed
async def run_and_resume_after_approval(
agent: Agent[Any],
model: Any,
raw_call: Any,
final_output: Any,
*,
user_input: str,
) -> RunResult:
"""Run, approve the first interruption, and resume."""
model.set_next_output([raw_call])
first = await Runner.run(agent, user_input)
state = approve_first_interruption(first, always_approve=True)
model.set_next_output([final_output])
return await Runner.run(agent, state)
def collect_tool_outputs(
items: Iterable[Any],
*,
output_type: str,
) -> list[ToolCallOutputItem]:
"""Return ToolCallOutputItems matching a raw_item type."""
return [
item
for item in items
if isinstance(item, ToolCallOutputItem)
and isinstance(item.raw_item, dict)
and item.raw_item.get("type") == output_type
]
async def consume_stream(result: Any) -> None:
"""Drain all stream events to completion."""
async for _ in result.stream_events():
pass
def assert_single_approval_interruption(
result: SingleStepResult,
*,
tool_name: str | None = None,
) -> ToolApprovalItem:
"""Assert the result contains exactly one approval interruption and return it."""
assert isinstance(result.next_step, NextStepInterruption)
assert len(result.next_step.interruptions) == 1
interruption = result.next_step.interruptions[0]
assert isinstance(interruption, ToolApprovalItem)
if tool_name:
assert interruption.tool_name == tool_name
return interruption
async def require_approval(
_ctx: Any | None = None, _params: Any = None, _call_id: str | None = None
) -> bool:
"""Approval helper that always requires a HITL decision."""
return True
class RecordingEditor:
"""Editor that records operations for testing."""
def __init__(self) -> None:
self.operations: list[Any] = []
def create_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Created {operation.path}", "status": "completed"}
def update_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Updated {operation.path}", "status": "completed"}
def delete_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Deleted {operation.path}", "status": "completed"}
def make_shell_call(
call_id: str,
*,
id_value: str | None = None,
commands: list[str] | None = None,
status: str = "in_progress",
) -> TResponseOutputItem:
"""Build a shell_call payload with optional overrides."""
return cast(
TResponseOutputItem,
{
"type": "shell_call",
"id": id_value or call_id,
"call_id": call_id,
"status": status,
"action": {"type": "exec", "commands": commands or ["echo test"], "timeout_ms": 1000},
},
)
def make_apply_patch_dict(call_id: str, diff: str = "-a\n+b\n") -> TResponseOutputItem:
"""Create an apply_patch_call dict payload."""
return cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"call_id": call_id,
"operation": {"type": "update_file", "path": "test.md", "diff": diff},
},
)
def make_function_tool_call(
name: str,
*,
call_id: str = "call-1",
arguments: str = "{}",
namespace: str | None = None,
) -> ResponseFunctionToolCall:
"""Create a ResponseFunctionToolCall for HITL scenarios."""
if namespace is None:
return ResponseFunctionToolCall(
type="function_call",
name=name,
call_id=call_id,
arguments=arguments,
)
return ResponseFunctionToolCall(
type="function_call",
name=name,
call_id=call_id,
arguments=arguments,
namespace=namespace,
)
def queue_function_call_and_text(
model: FakeModel,
function_call: TResponseOutputItem,
*,
first_turn_extra: Sequence[TResponseOutputItem] | None = None,
followup: Sequence[TResponseOutputItem] | None = None,
) -> None:
"""Queue a function call turn followed by a follow-up turn on the fake model."""
raw_type = (
function_call.get("type")
if isinstance(function_call, dict)
else getattr(function_call, "type", None)
)
assert raw_type == "function_call", "queue_function_call_and_text expects a function call item"
model.add_multiple_turn_outputs(
[
[function_call, *(first_turn_extra or [])],
list(followup or []),
]
)
async def run_and_resume_with_mutation(
agent: Agent[Any],
model: Any,
turn_outputs: Sequence[Sequence[Any]],
*,
user_input: str,
mutate_state: Callable[[RunStateClass[Any, Agent[Any]], ToolApprovalItem], None] | None = None,
) -> tuple[RunResult, RunResult]:
"""Run until interruption, optionally mutate state, then resume."""
model.add_multiple_turn_outputs(turn_outputs)
first = await Runner.run(agent, input=user_input)
assert first.interruptions, "expected an approval interruption"
state = first.to_state()
if mutate_state and first.interruptions:
mutate_state(state, first.interruptions[0])
resumed = await Runner.run(agent, input=state)
return first, resumed
async def assert_pending_resume(
tool: Any,
model: Any,
raw_call: TResponseOutputItem,
*,
user_input: str,
output_type: str,
) -> RunResult:
"""Run, resume, and assert pending approvals stay pending."""
agent = make_agent(model=model, tools=[tool])
resumed = await run_and_resume(agent, model, raw_call, user_input=user_input)
assert resumed.interruptions, "pending approval should remain after resuming"
assert any(
isinstance(item, ToolApprovalItem) and item.tool_name == tool.name
for item in resumed.interruptions
)
assert not collect_tool_outputs(resumed.new_items, output_type=output_type), (
f"{output_type} should not execute without approval"
)
return resumed
def make_mcp_raw_item(
*,
call_id: str = "call_mcp_1",
include_provider_data: bool = True,
tool_name: str = "test_mcp_tool",
provider_data: dict[str, Any] | None = None,
include_name: bool = True,
use_call_id: bool = True,
) -> dict[str, Any]:
"""Build a hosted MCP tool call payload for approvals."""
raw_item: dict[str, Any] = {"type": "hosted_tool_call"}
if include_name:
raw_item["name"] = tool_name
if include_provider_data:
if use_call_id:
raw_item["call_id"] = call_id
else:
raw_item["id"] = call_id
raw_item["provider_data"] = provider_data or {
"type": "mcp_approval_request",
"id": "req-1",
"server_label": "test_server",
}
else:
raw_item["id"] = call_id
return raw_item
def make_mcp_approval_item(
agent: Agent[Any],
*,
call_id: str = "call_mcp_1",
include_provider_data: bool = True,
tool_name: str | None = "test_mcp_tool",
provider_data: dict[str, Any] | None = None,
include_name: bool = True,
use_call_id: bool = True,
) -> ToolApprovalItem:
"""Create a ToolApprovalItem for MCP or hosted tool calls."""
raw_item = make_mcp_raw_item(
call_id=call_id,
include_provider_data=include_provider_data,
tool_name=tool_name or "unknown_mcp_tool",
provider_data=provider_data,
include_name=include_name,
use_call_id=use_call_id,
)
return ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
def make_context_wrapper() -> RunContextWrapper[dict[str, Any]]:
"""Create an empty RunContextWrapper for HITL tests."""
return RunContextWrapper(context={})
def make_agent(
*,
model: Any | None = None,
tools: Sequence[Any] | None = None,
name: str = "TestAgent",
) -> Agent[Any]:
"""Build a test Agent with optional model and tools."""
return Agent(name=name, model=model, tools=list(tools or []))
def make_model_and_agent(
*,
tools: Sequence[Any] | None = None,
name: str = "TestAgent",
) -> tuple[FakeModel, Agent[Any]]:
"""Build a FakeModel with a paired Agent for HITL tests."""
model = FakeModel()
agent = make_agent(model=model, tools=tools, name=name)
return model, agent
def reject_tool_call(
context_wrapper: RunContextWrapper[Any],
agent: Agent[Any],
raw_item: Any,
tool_name: str,
*,
rejection_message: str | None = None,
) -> ToolApprovalItem:
"""Reject a tool call in the context and return the approval item used."""
approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
context_wrapper.reject_tool(approval_item, rejection_message=rejection_message)
return approval_item
def make_on_approval_callback(
approve: bool,
*,
reason: str | None = None,
) -> Callable[[RunContextWrapper[Any], ToolApprovalItem], Awaitable[Any]]:
"""Build an on_approval callback that always approves or rejects."""
async def on_approval(
_ctx: RunContextWrapper[Any], _approval_item: ToolApprovalItem
) -> dict[str, Any]:
payload: dict[str, Any] = {"approve": approve}
if reason:
payload["reason"] = reason
return payload
return on_approval
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from typing import cast
from agents.items import TResponseInputItem
from agents.memory.session import Session
from agents.memory.session_settings import SessionSettings
class SimpleListSession(Session):
"""A minimal in-memory session implementation for tests."""
session_settings: SessionSettings | None = None
def __init__(
self,
session_id: str = "test",
history: list[TResponseInputItem] | None = None,
) -> None:
self.session_id = session_id
self._items: list[TResponseInputItem] = list(history) if history else []
# Some session implementations strip IDs on write; tests can opt-in via attribute.
self._ignore_ids_for_matching = False
# Mirror saved_items used by some tests for inspection.
self.saved_items: list[TResponseInputItem] = self._items
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
if limit is None:
return list(self._items)
if limit <= 0:
return []
return self._items[-limit:]
async def add_items(self, items: list[TResponseInputItem]) -> None:
self._items.extend(items)
async def pop_item(self) -> TResponseInputItem | None:
if not self._items:
return None
return self._items.pop()
async def clear_session(self) -> None:
self._items.clear()
class CountingSession(SimpleListSession):
"""Session that tracks how many times pop_item is invoked (for rewind tests)."""
def __init__(
self,
session_id: str = "test",
history: list[TResponseInputItem] | None = None,
) -> None:
super().__init__(session_id=session_id, history=history)
self.pop_calls = 0
async def pop_item(self) -> TResponseInputItem | None:
self.pop_calls += 1
return await super().pop_item()
class IdStrippingSession(CountingSession):
"""Session that strips IDs on add to mimic hosted stores that reassign IDs."""
def __init__(
self,
session_id: str = "test",
history: list[TResponseInputItem] | None = None,
) -> None:
super().__init__(session_id=session_id, history=history)
self._ignore_ids_for_matching = True
async def add_items(self, items: list[TResponseInputItem]) -> None:
sanitized: list[TResponseInputItem] = []
for item in items:
if isinstance(item, dict):
clean = dict(item)
clean.pop("id", None)
sanitized.append(cast(TResponseInputItem, clean))
else:
sanitized.append(item)
await super().add_items(sanitized)
+43
View File
@@ -0,0 +1,43 @@
import json
from types import MappingProxyType
from openai.types.responses.response_output_message_param import ResponseOutputMessageParam
from openai.types.responses.response_output_text_param import ResponseOutputTextParam
from agents.util._json import _to_dump_compatible
def test_to_dump_compatible():
# Given a list of message dictionaries, ensure the returned list is a deep copy.
input_iter = [
ResponseOutputMessageParam(
id="a75654dc-7492-4d1c-bce0-89e8312fbdd7",
content=[
ResponseOutputTextParam(
type="output_text",
text="Hey, what's up?",
annotations=[],
logprobs=[],
)
].__iter__(),
role="assistant",
status="completed",
type="message",
)
].__iter__()
# this fails if any of the properties are Iterable objects.
# result = json.dumps(input_iter)
result = json.dumps(_to_dump_compatible(input_iter))
assert (
result
== """[{"id": "a75654dc-7492-4d1c-bce0-89e8312fbdd7", "content": [{"type": "output_text", "text": "Hey, what's up?", "annotations": [], "logprobs": []}], "role": "assistant", "status": "completed", "type": "message"}]""" # noqa: E501
)
def test_to_dump_compatible_preserves_non_dict_mapping_values():
# A non-dict Mapping (e.g. MappingProxyType) must be preserved as an object,
# recursing into its values, instead of collapsing to a list of its keys.
out = _to_dump_compatible({"config": MappingProxyType({"timeout": 30, "retries": 3})})
assert out == {"config": {"timeout": 30, "retries": 3}}
# A top-level mapping is preserved as an object, not flattened to its keys.
assert _to_dump_compatible(MappingProxyType({"a": 1, "b": 2})) == {"a": 1, "b": 2}
@@ -0,0 +1,88 @@
from __future__ import annotations
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from agents import Agent
from agents.exceptions import RunErrorDetails
from agents.items import ItemHelpers, MessageOutputItem
from agents.util._pretty_print import pretty_print_run_error_details
def _make_message_item(text: str | None) -> MessageOutputItem:
msg = ResponseOutputMessage.model_construct(
id="msg_1",
role="assistant",
status="completed",
content=[ResponseOutputText.model_construct(type="output_text", text=text, annotations=[])],
)
agent = Agent(name="test")
return MessageOutputItem(agent=agent, raw_item=msg)
def test_text_message_output_returns_empty_string_for_none_text():
"""text_message_output must not crash when a content item has text=None."""
item = _make_message_item(None)
assert ItemHelpers.text_message_output(item) == ""
def test_text_message_output_returns_text_normally():
item = _make_message_item("hello")
assert ItemHelpers.text_message_output(item) == "hello"
def test_text_message_outputs_handles_none_text_across_items():
"""text_message_outputs must tolerate None text in any item."""
from agents.items import RunItem
items: list[RunItem] = [_make_message_item(None), _make_message_item("world")]
assert ItemHelpers.text_message_outputs(items) == "world"
def _make_output_message(text: str | None) -> ResponseOutputMessage:
return ResponseOutputMessage.model_construct(
id="msg_1",
role="assistant",
status="completed",
content=[ResponseOutputText.model_construct(type="output_text", text=text, annotations=[])],
)
def test_extract_last_content_returns_empty_string_for_none_text():
"""extract_last_content is declared `-> str` and must not return None even if
the underlying ResponseOutputText.text is None (observed via LiteLLM gateways
and ``model_construct`` paths during streaming, per items.py:714-720)."""
msg = _make_output_message(None)
result = ItemHelpers.extract_last_content(msg)
assert isinstance(result, str)
assert result == ""
def test_extract_last_content_returns_text_normally():
msg = _make_output_message("hello")
assert ItemHelpers.extract_last_content(msg) == "hello"
def _make_run_error_details(n_input: int = 0, n_output: int = 0) -> RunErrorDetails:
return RunErrorDetails(
input="hi",
new_items=[],
raw_responses=[],
last_agent=Agent(name="test"),
context_wrapper=None, # type: ignore[arg-type]
input_guardrail_results=[None] * n_input, # type: ignore[list-item]
output_guardrail_results=[None] * n_output, # type: ignore[list-item]
)
def test_pretty_print_run_error_details_includes_output_guardrail_count():
"""pretty_print_run_error_details must report output_guardrail_results like its siblings."""
details = _make_run_error_details(n_input=1, n_output=2)
text = pretty_print_run_error_details(details)
assert "1 input guardrail result(s)" in text
assert "2 output guardrail result(s)" in text
def test_pretty_print_run_error_details_zero_output_guardrails():
details = _make_run_error_details(n_input=0, n_output=0)
text = pretty_print_run_error_details(details)
assert "0 output guardrail result(s)" in text
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import cast
import pytest
from agents.items import TResponseInputItem
from tests.utils.simple_session import CountingSession, IdStrippingSession, SimpleListSession
@pytest.mark.asyncio
async def test_simple_list_session_preserves_history_and_saved_items() -> None:
history: list[TResponseInputItem] = [
cast(TResponseInputItem, {"id": "msg1", "content": "hi", "role": "user"}),
cast(TResponseInputItem, {"id": "msg2", "content": "hello", "role": "assistant"}),
]
session = SimpleListSession(history=history)
items = await session.get_items()
# get_items should return a copy, not the original list.
assert items == history
assert items is not history
# saved_items should mirror the stored list.
assert session.saved_items == history
@pytest.mark.asyncio
async def test_counting_session_tracks_pop_calls() -> None:
session = CountingSession(
history=[cast(TResponseInputItem, {"id": "x", "content": "hi", "role": "user"})]
)
assert session.pop_calls == 0
await session.pop_item()
assert session.pop_calls == 1
await session.pop_item()
assert session.pop_calls == 2
@pytest.mark.asyncio
async def test_id_stripping_session_removes_ids_on_add() -> None:
session = IdStrippingSession()
items: list[TResponseInputItem] = [
cast(TResponseInputItem, {"id": "keep-removed", "content": "hello", "role": "user"}),
cast(TResponseInputItem, {"content": "no-id", "role": "assistant"}),
]
await session.add_items(items)
stored = await session.get_items()
assert all("id" not in item for item in stored if isinstance(item, dict))
# pop_calls should increment when rewinding.
await session.pop_item()
assert session.pop_calls == 1