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
@@ -0,0 +1,757 @@
from __future__ import annotations
import asyncio
import importlib
import inspect
import json
import os
from dataclasses import fields
from pathlib import Path
from typing import Any, cast
import pytest
from agents.exceptions import UserError
from agents.extensions.experimental.codex import Usage
from agents.extensions.experimental.codex.codex import Codex, _normalize_env
from agents.extensions.experimental.codex.codex_options import CodexOptions, coerce_codex_options
from agents.extensions.experimental.codex.exec import CodexExec
from agents.extensions.experimental.codex.output_schema_file import (
OutputSchemaFile,
create_output_schema_file,
)
from agents.extensions.experimental.codex.thread import Thread, _normalize_input
from agents.extensions.experimental.codex.thread_options import ThreadOptions, coerce_thread_options
from agents.extensions.experimental.codex.turn_options import TurnOptions
exec_module = importlib.import_module("agents.extensions.experimental.codex.exec")
thread_module = importlib.import_module("agents.extensions.experimental.codex.thread")
output_schema_module = importlib.import_module(
"agents.extensions.experimental.codex.output_schema_file"
)
class FakeStdin:
def __init__(self) -> None:
self.buffer = b""
self.closed = False
def write(self, data: bytes) -> None:
self.buffer += data
async def drain(self) -> None:
return None
def close(self) -> None:
self.closed = True
class FakeStdout:
def __init__(self, lines: list[str]) -> None:
self._lines = [line.encode("utf-8") for line in lines]
async def readline(self) -> bytes:
if not self._lines:
return b""
return self._lines.pop(0)
class FakeStderr:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = list(chunks)
async def read(self, _size: int) -> bytes:
if not self._chunks:
return b""
return self._chunks.pop(0)
class FakeProcess:
def __init__(
self,
stdout_lines: list[str],
stderr_chunks: list[bytes] | None = None,
*,
returncode: int | None = 0,
stdin_present: bool = True,
stdout_present: bool = True,
stderr_present: bool = True,
) -> None:
self.stdin = FakeStdin() if stdin_present else None
self.stdout = FakeStdout(stdout_lines) if stdout_present else None
self.stderr = FakeStderr(stderr_chunks or []) if stderr_present else None
self.returncode = returncode
self.killed = False
self.terminated = False
async def wait(self) -> None:
if self.returncode is None:
self.returncode = 0
def kill(self) -> None:
self.killed = True
def terminate(self) -> None:
self.terminated = True
class FakeExec:
def __init__(self, events: list[Any], delay: float = 0.0) -> None:
self.events = events
self.delay = delay
self.last_args: Any = None
async def run(self, args: Any):
self.last_args = args
for event in self.events:
if self.delay:
await asyncio.sleep(self.delay)
payload = event if isinstance(event, str) else json.dumps(event)
yield payload
def test_output_schema_file_none_schema() -> None:
result = create_output_schema_file(None)
assert result.schema_path is None
result.cleanup()
def test_output_schema_file_rejects_non_object() -> None:
with pytest.raises(UserError, match="output_schema must be a plain JSON object"):
create_output_schema_file(cast(Any, ["not", "an", "object"]))
def test_output_schema_file_creates_and_cleans() -> None:
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
result = create_output_schema_file(schema)
assert result.schema_path is not None
with open(result.schema_path, encoding="utf-8") as handle:
assert json.load(handle) == schema
result.cleanup()
assert not os.path.exists(result.schema_path)
def test_output_schema_file_cleanup_swallows_rmtree_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
schema = {"type": "object"}
called = False
def bad_rmtree(_path: str, ignore_errors: bool = True) -> None:
nonlocal called
called = True
raise OSError("boom")
monkeypatch.setattr(output_schema_module.shutil, "rmtree", bad_rmtree)
result = create_output_schema_file(schema)
result.cleanup()
assert called is True
def test_output_schema_file_cleanup_on_write_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
schema = {"type": "object"}
cleanup_called = False
def bad_dump(*_args: Any, **_kwargs: Any) -> None:
raise RuntimeError("boom")
def fake_rmtree(_path: str, ignore_errors: bool = True) -> None:
nonlocal cleanup_called
cleanup_called = True
monkeypatch.setattr(output_schema_module.json, "dump", bad_dump)
monkeypatch.setattr(output_schema_module.shutil, "rmtree", fake_rmtree)
with pytest.raises(RuntimeError, match="boom"):
create_output_schema_file(schema)
assert cleanup_called is True
def test_normalize_input_merges_text_and_images() -> None:
prompt, images = _normalize_input(
[
{"type": "text", "text": "first"},
{"type": "local_image", "path": "/tmp/a.png"},
{"type": "text", "text": "second"},
{"type": "local_image", "path": ""},
]
)
assert prompt == "first\n\nsecond"
assert images == ["/tmp/a.png"]
def test_normalize_env_stringifies_values() -> None:
env = _normalize_env(CodexOptions(env=cast(dict[str, str], {"FOO": 1, 2: "bar"})))
assert env == {"FOO": "1", "2": "bar"}
def test_coerce_codex_options_rejects_unknown_fields() -> None:
with pytest.raises(UserError, match="Unknown CodexOptions field"):
coerce_codex_options({"unknown": "value"})
def test_coerce_thread_options_rejects_unknown_fields() -> None:
with pytest.raises(UserError, match="Unknown ThreadOptions field"):
coerce_thread_options({"unknown": "value"})
def test_coerce_thread_options_rejects_non_mapping() -> None:
with pytest.raises(UserError, match="ThreadOptions must be a ThreadOptions or a mapping"):
coerce_thread_options(cast(Any, ["model", "gpt"]))
def test_codex_start_and_resume_thread() -> None:
codex = Codex(CodexOptions(codex_path_override="/bin/codex"))
thread = codex.start_thread({"model": "gpt"})
assert thread.id is None
resumed = codex.resume_thread("thread-1", {"model": "gpt"})
assert resumed.id == "thread-1"
def test_codex_init_accepts_mapping_options() -> None:
codex = Codex({"codex_path_override": "/bin/codex"})
assert codex._exec._executable_path == "/bin/codex"
def test_codex_init_accepts_kwargs() -> None:
codex = Codex(codex_path_override="/bin/codex", base_url="https://example.com")
assert codex._exec._executable_path == "/bin/codex"
assert codex._options.base_url == "https://example.com"
def test_codex_init_accepts_stream_limit_kwarg() -> None:
codex = Codex(codex_path_override="/bin/codex", codex_subprocess_stream_limit_bytes=123456)
assert codex._exec._subprocess_stream_limit_bytes == 123456
def test_codex_init_rejects_options_and_kwargs() -> None:
with pytest.raises(UserError, match="Codex options must be provided"):
Codex( # type: ignore[call-overload]
cast(Any, CodexOptions()), codex_path_override="/bin/codex"
)
def test_codex_init_kw_matches_codex_options() -> None:
signature = inspect.signature(Codex.__init__)
kw_only = [
param.name
for param in signature.parameters.values()
if param.kind == inspect.Parameter.KEYWORD_ONLY
]
option_fields = [field.name for field in fields(CodexOptions)]
assert kw_only == option_fields
def test_codex_exec_stream_limit_uses_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(exec_module._SUBPROCESS_STREAM_LIMIT_ENV_VAR, "131072")
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
assert exec_client._subprocess_stream_limit_bytes == 131072
def test_codex_exec_stream_limit_explicit_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(exec_module._SUBPROCESS_STREAM_LIMIT_ENV_VAR, "262144")
exec_client = exec_module.CodexExec(
executable_path="/bin/codex",
subprocess_stream_limit_bytes=524288,
)
assert exec_client._subprocess_stream_limit_bytes == 524288
def test_codex_exec_stream_limit_rejects_invalid_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(exec_module._SUBPROCESS_STREAM_LIMIT_ENV_VAR, "not-a-number")
with pytest.raises(UserError, match=exec_module._SUBPROCESS_STREAM_LIMIT_ENV_VAR):
_ = exec_module.CodexExec(executable_path="/bin/codex")
def test_codex_exec_stream_limit_rejects_out_of_range_value() -> None:
with pytest.raises(UserError, match="must be between"):
_ = exec_module.CodexExec(
executable_path="/bin/codex",
subprocess_stream_limit_bytes=1024,
)
@pytest.mark.asyncio
async def test_codex_exec_run_builds_command_args_and_env(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
process = FakeProcess(stdout_lines=["line-1\n", "line-2\n"])
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess:
captured["args"] = args
captured["kwargs"] = kwargs
return process
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex", env={"FOO": "bar"})
args = exec_module.CodexExecArgs(
input="hello",
base_url="https://example.com",
api_key="api-key",
thread_id="thread-123",
images=["/tmp/img.png"],
model="gpt-4.1-mini",
sandbox_mode="read-only",
working_directory="/work",
additional_directories=["/extra-a", "/extra-b"],
skip_git_repo_check=True,
output_schema_file="/tmp/schema.json",
model_reasoning_effort="high",
network_access_enabled=True,
web_search_mode="live",
approval_policy="on-request",
)
output = [line async for line in exec_client.run(args)]
assert output == ["line-1", "line-2"]
assert process.stdin is not None
assert process.stdin.buffer == b"hello"
assert process.stdin.closed is True
assert captured["args"][0] == "/bin/codex"
assert list(captured["args"][1:]) == [
"exec",
"--experimental-json",
"--model",
"gpt-4.1-mini",
"--sandbox",
"read-only",
"--cd",
"/work",
"--add-dir",
"/extra-a",
"--add-dir",
"/extra-b",
"--skip-git-repo-check",
"--output-schema",
"/tmp/schema.json",
"--config",
'model_reasoning_effort="high"',
"--config",
"sandbox_workspace_write.network_access=true",
"--config",
'web_search="live"',
"--config",
'approval_policy="on-request"',
"resume",
"thread-123",
"--image",
"/tmp/img.png",
"-",
]
env = captured["kwargs"]["env"]
assert env["FOO"] == "bar"
assert env[exec_module._INTERNAL_ORIGINATOR_ENV] == exec_module._TYPESCRIPT_SDK_ORIGINATOR
assert env["OPENAI_BASE_URL"] == "https://example.com"
assert env["CODEX_API_KEY"] == "api-key"
@pytest.mark.asyncio
async def test_codex_exec_run_handles_large_single_line_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
large_payload = "x" * (2**16 + 1)
class StreamReaderProcess:
def __init__(self, *, line: str, limit: int) -> None:
self.stdin = FakeStdin()
self.stdout = asyncio.StreamReader(limit=limit)
self.stdout.feed_data(f"{line}\n".encode())
self.stdout.feed_eof()
self.stderr = FakeStderr([])
self.returncode: int | None = 0
self.killed = False
self.terminated = False
async def wait(self) -> None:
if self.returncode is None:
self.returncode = 0
def kill(self) -> None:
self.killed = True
def terminate(self) -> None:
self.terminated = True
async def fake_create_subprocess_exec(*_args: Any, **kwargs: Any) -> StreamReaderProcess:
captured["kwargs"] = kwargs
return StreamReaderProcess(line=large_payload, limit=kwargs["limit"])
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
output = [line async for line in exec_client.run(exec_module.CodexExecArgs(input="hello"))]
assert output == [large_payload]
assert captured["kwargs"]["limit"] == exec_module._DEFAULT_SUBPROCESS_STREAM_LIMIT_BYTES
@pytest.mark.asyncio
@pytest.mark.parametrize(
("enabled", "expected_config"),
[
(True, 'web_search="live"'),
(False, 'web_search="disabled"'),
],
)
async def test_codex_exec_run_web_search_enabled_flags(
monkeypatch: pytest.MonkeyPatch, enabled: bool, expected_config: str
) -> None:
captured: dict[str, Any] = {}
process = FakeProcess(stdout_lines=[])
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess:
captured["args"] = args
return process
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
args = exec_module.CodexExecArgs(input="hello", web_search_enabled=enabled)
_ = [line async for line in exec_client.run(args)]
command_args = list(captured["args"][1:])
assert "--config" in command_args
assert expected_config in command_args
@pytest.mark.asyncio
async def test_codex_exec_run_raises_on_non_zero_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
process = FakeProcess(stdout_lines=[], stderr_chunks=[b"bad"], returncode=2)
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess:
return process
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
args = exec_module.CodexExecArgs(input="hello")
with pytest.raises(RuntimeError, match="exited with code 2"):
async for _ in exec_client.run(args):
pass
@pytest.mark.asyncio
async def test_codex_exec_run_raises_without_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
process = FakeProcess(stdout_lines=[], stdin_present=False)
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess:
return process
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
args = exec_module.CodexExecArgs(input="hello")
with pytest.raises(RuntimeError, match="no stdin"):
async for _ in exec_client.run(args):
pass
assert process.killed is True
@pytest.mark.asyncio
async def test_codex_exec_run_raises_without_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
process = FakeProcess(stdout_lines=[], stdout_present=False)
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> FakeProcess:
return process
monkeypatch.setattr(exec_module.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
exec_client = exec_module.CodexExec(executable_path="/bin/codex")
args = exec_module.CodexExecArgs(input="hello")
with pytest.raises(RuntimeError, match="no stdout"):
async for _ in exec_client.run(args):
pass
assert process.killed is True
@pytest.mark.asyncio
async def test_watch_signal_terminates_process() -> None:
signal = asyncio.Event()
process = FakeProcess(stdout_lines=[], returncode=None)
task = asyncio.create_task(exec_module._watch_signal(signal, process))
signal.set()
await task
assert process.terminated is True
@pytest.mark.parametrize(
("system", "arch", "expected"),
[
("linux", "x86_64", "x86_64-unknown-linux-musl"),
("linux", "aarch64", "aarch64-unknown-linux-musl"),
("darwin", "x86_64", "x86_64-apple-darwin"),
("darwin", "arm64", "aarch64-apple-darwin"),
("win32", "x86_64", "x86_64-pc-windows-msvc"),
("win32", "arm64", "aarch64-pc-windows-msvc"),
],
)
def test_platform_target_triple_mapping(
monkeypatch: pytest.MonkeyPatch, system: str, arch: str, expected: str
) -> None:
monkeypatch.setattr(exec_module.sys, "platform", system)
monkeypatch.setattr(exec_module.platform, "machine", lambda: arch)
assert exec_module._platform_target_triple() == expected
def test_platform_target_triple_unsupported(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(exec_module.sys, "platform", "solaris")
monkeypatch.setattr(exec_module.platform, "machine", lambda: "sparc")
with pytest.raises(RuntimeError, match="Unsupported platform"):
exec_module._platform_target_triple()
def test_find_codex_path_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CODEX_PATH", "/custom/codex")
assert exec_module.find_codex_path() == "/custom/codex"
def test_find_codex_path_uses_shutil_which(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CODEX_PATH", raising=False)
monkeypatch.setattr(exec_module.shutil, "which", lambda _name: "/usr/local/bin/codex")
assert exec_module.find_codex_path() == "/usr/local/bin/codex"
def test_find_codex_path_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CODEX_PATH", raising=False)
monkeypatch.setattr(exec_module.shutil, "which", lambda _name: None)
monkeypatch.setattr(exec_module, "_platform_target_triple", lambda: "dummy-triple")
monkeypatch.setattr(exec_module.sys, "platform", "linux")
result = exec_module.find_codex_path()
expected_root = (
Path(cast(str, exec_module.__file__)).resolve().parent.parent.parent
/ "vendor"
/ "dummy-triple"
/ "codex"
/ "codex"
)
assert result == str(expected_root)
@pytest.mark.asyncio
async def test_thread_run_streamed_passes_options_and_updates_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [
{"type": "thread.started", "thread_id": "thread-42"},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
fake_exec = FakeExec(events)
options = CodexOptions(base_url="https://example.com", api_key="api-key")
thread_options = ThreadOptions(
model="gpt-4.1-mini",
sandbox_mode="read-only",
working_directory="/work",
skip_git_repo_check=True,
model_reasoning_effort="low",
network_access_enabled=False,
web_search_mode="cached",
approval_policy="on-request",
additional_directories=["/extra"],
)
thread = Thread(
exec_client=cast(CodexExec, fake_exec),
options=options,
thread_options=thread_options,
)
cleanup_called = False
def fake_create_output_schema_file(schema: dict[str, Any] | None) -> OutputSchemaFile:
nonlocal cleanup_called
def cleanup() -> None:
nonlocal cleanup_called
cleanup_called = True
return OutputSchemaFile(schema_path="/tmp/schema.json", cleanup=cleanup)
monkeypatch.setattr(thread_module, "create_output_schema_file", fake_create_output_schema_file)
streamed = await thread.run_streamed(
[
{"type": "text", "text": "hello"},
{"type": "local_image", "path": "/tmp/a.png"},
],
TurnOptions(output_schema={"type": "object"}),
)
collected = [event async for event in streamed.events]
assert collected[0].type == "thread.started"
assert thread.id == "thread-42"
assert cleanup_called is True
assert fake_exec.last_args is not None
assert fake_exec.last_args.output_schema_file == "/tmp/schema.json"
assert fake_exec.last_args.model == "gpt-4.1-mini"
assert fake_exec.last_args.sandbox_mode == "read-only"
assert fake_exec.last_args.working_directory == "/work"
assert fake_exec.last_args.skip_git_repo_check is True
assert fake_exec.last_args.model_reasoning_effort == "low"
assert fake_exec.last_args.network_access_enabled is False
assert fake_exec.last_args.web_search_mode == "cached"
assert fake_exec.last_args.approval_policy == "on-request"
assert fake_exec.last_args.additional_directories == ["/extra"]
assert fake_exec.last_args.images == ["/tmp/a.png"]
@pytest.mark.asyncio
async def test_thread_run_aggregates_items_and_usage() -> None:
events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "done"},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 3},
},
]
thread = Thread(
exec_client=cast(CodexExec, FakeExec(events)),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
result = await thread.run("hello")
assert result.final_response == "done"
assert result.usage == Usage(
input_tokens=2,
cached_input_tokens=1,
output_tokens=3,
)
assert len(result.items) == 1
@pytest.mark.asyncio
async def test_thread_run_raises_on_failure() -> None:
events = [
{"type": "turn.failed", "error": {"message": "boom"}},
]
thread = Thread(
exec_client=cast(CodexExec, FakeExec(events)),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
with pytest.raises(RuntimeError, match="boom"):
await thread.run("hello")
@pytest.mark.asyncio
async def test_thread_run_raises_on_stream_error() -> None:
events = [
{"type": "error", "message": "boom"},
]
thread = Thread(
exec_client=cast(CodexExec, FakeExec(events)),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
with pytest.raises(RuntimeError, match="Codex stream error: boom"):
await thread.run("hello")
@pytest.mark.asyncio
async def test_thread_run_streamed_raises_on_parse_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = ["not-json"]
fake_exec = FakeExec(events)
thread = Thread(
exec_client=cast(CodexExec, fake_exec),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
def fake_create_output_schema_file(schema: dict[str, Any] | None) -> OutputSchemaFile:
return OutputSchemaFile(schema_path=None, cleanup=lambda: None)
monkeypatch.setattr(thread_module, "create_output_schema_file", fake_create_output_schema_file)
streamed = await thread.run_streamed("hello")
with pytest.raises(RuntimeError, match="Failed to parse event"):
async for _ in streamed.events:
pass
@pytest.mark.asyncio
async def test_thread_run_streamed_idle_timeout_sets_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
}
]
fake_exec = FakeExec(events, delay=0.2)
thread = Thread(
exec_client=cast(CodexExec, fake_exec),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
signal = asyncio.Event()
def fake_create_output_schema_file(schema: dict[str, Any] | None) -> OutputSchemaFile:
return OutputSchemaFile(schema_path=None, cleanup=lambda: None)
monkeypatch.setattr(thread_module, "create_output_schema_file", fake_create_output_schema_file)
with pytest.raises(RuntimeError, match="Codex stream idle for"):
async for _ in thread._run_streamed_internal(
"hello", TurnOptions(signal=signal, idle_timeout_seconds=0.01)
):
pass
assert signal.is_set() is True
@pytest.mark.asyncio
async def test_thread_run_streamed_idle_timeout_creates_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
}
]
fake_exec = FakeExec(events, delay=0.2)
thread = Thread(
exec_client=cast(CodexExec, fake_exec),
options=CodexOptions(),
thread_options=ThreadOptions(),
)
def fake_create_output_schema_file(schema: dict[str, Any] | None) -> OutputSchemaFile:
return OutputSchemaFile(schema_path=None, cleanup=lambda: None)
monkeypatch.setattr(thread_module, "create_output_schema_file", fake_create_output_schema_file)
with pytest.raises(RuntimeError, match="Codex stream idle for"):
async for _ in thread._run_streamed_internal(
"hello", TurnOptions(idle_timeout_seconds=0.01)
):
pass
assert fake_exec.last_args is not None
assert fake_exec.last_args.signal is not None
assert fake_exec.last_args.signal.is_set() is True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
from __future__ import annotations
import pytest
from agents.extensions.experimental.codex.items import AgentMessageItem, TodoItem, TodoListItem
def test_dict_like_supports_mapping_access_for_dataclass_fields() -> None:
item = AgentMessageItem(id="item-1", text="hello")
assert item["id"] == "item-1"
assert item["text"] == "hello"
assert item["type"] == "agent_message"
assert item.get("text") == "hello"
assert item.get("missing", "fallback") == "fallback"
assert "id" in item
assert "missing" not in item
assert object() not in item
assert list(item.keys()) == ["id", "text", "type"]
def test_dict_like_raises_key_error_for_unknown_fields() -> None:
item = AgentMessageItem(id="item-1", text="hello")
with pytest.raises(KeyError, match="missing"):
_ = item["missing"]
def test_dict_like_as_dict_recursively_converts_nested_dataclasses() -> None:
item = TodoListItem(
id="todo-list-1",
items=[
TodoItem(text="write tests", completed=True),
TodoItem(text="run tests", completed=False),
],
)
assert item.as_dict() == {
"id": "todo-list-1",
"items": [
{"text": "write tests", "completed": True},
{"text": "run tests", "completed": False},
],
"type": "todo_list",
}
@@ -0,0 +1,179 @@
from __future__ import annotations
import json
import os
from typing import Any
import pytest
from openai import AsyncOpenAI
from agents import Agent, ModelSettings, RunConfig, Runner, function_tool
from agents.extensions.experimental.hosted_multi_agent import (
HostedMultiAgentConfig,
OpenAIHostedMultiAgentModel,
get_hosted_agent_metadata,
)
from agents.tool_context import ToolContext
pytestmark = [
pytest.mark.allow_call_model_methods,
pytest.mark.skipif(
os.environ.get("OPENAI_RUN_LIVE_HOSTED_MULTI_AGENT_TESTS") != "1",
reason="Set OPENAI_RUN_LIVE_HOSTED_MULTI_AGENT_TESTS=1 to run live beta tests.",
),
]
_PROPOSALS = {
"alpha": {"estimated_weeks": 6, "risk": "medium"},
"beta": {"estimated_weeks": 8, "risk": "low"},
}
def _tool_output(arguments: str) -> str:
proposal = json.loads(arguments)["proposal"]
return json.dumps(_PROPOSALS[proposal], sort_keys=True)
async def _run_direct_baseline(client: AsyncOpenAI) -> tuple[str, set[str], set[str]]:
beta = getattr(client, "beta", None)
responses = getattr(beta, "responses", None)
connect = getattr(responses, "connect", None)
if not callable(connect):
pytest.fail("The installed openai package does not provide client.beta.responses.connect.")
tools = [
{
"type": "function",
"name": "get_proposal",
"description": "Return deterministic details for one proposal.",
"parameters": {
"type": "object",
"properties": {"proposal": {"type": "string", "enum": ["alpha", "beta"]}},
"required": ["proposal"],
"additionalProperties": False,
},
"strict": True,
}
]
callers: set[str] = set()
call_ids: set[str] = set()
completed_response: Any | None = None
response_id: str | None = None
pending_injections = 0
async with connect(
extra_headers={"OpenAI-Beta": "responses_multi_agent=v1"},
max_retries=0,
) as connection:
await connection.send(
{
"type": "response.create",
"model": "gpt-5.6-sol",
"input": [{"role": "user", "content": "Compare proposal alpha and proposal beta."}],
"instructions": (
"Create two subagents. Assign proposal alpha to one and proposal beta to the "
"other. Each subagent must call get_proposal, then the root must synthesize."
),
"tools": tools,
"store": True,
"multi_agent": {"enabled": True, "max_concurrent_subagents": 2},
}
)
async for event in connection:
if event.type == "response.created":
response_id = event.response.id
elif event.type == "response.output_item.done" and event.item.type == "function_call":
if response_id is None:
pytest.fail("Direct baseline received a function call before response.created.")
call_ids.add(event.item.call_id)
agent = getattr(event.item, "agent", None)
callers.add(getattr(agent, "agent_name", "/root"))
pending_injections += 1
await connection.send(
{
"type": "response.inject",
"response_id": response_id,
"input": [
{
"type": "function_call_output",
"call_id": event.item.call_id,
"output": _tool_output(event.item.arguments),
}
],
}
)
elif event.type == "response.inject.created":
pending_injections -= 1
elif event.type == "response.inject.failed":
pytest.fail(f"Direct baseline injection failed: {event.error}")
elif event.type == "response.completed":
completed_response = event.response
elif event.type in {"error", "response.failed", "response.incomplete"}:
pytest.fail(f"Direct baseline failed: {event}")
if completed_response is not None and pending_injections == 0:
break
if completed_response is None:
pytest.fail("Direct hosted multi-agent baseline did not complete.")
root_text: list[str] = []
for item in completed_response.output:
if (
item.type == "message"
and getattr(getattr(item, "agent", None), "agent_name", None) == "/root"
and getattr(item, "phase", None) == "final_answer"
):
root_text.extend(part.text for part in item.content if part.type == "output_text")
return "".join(root_text), callers, call_ids
@pytest.mark.asyncio
async def test_live_direct_and_agents_sdk_semantic_parity() -> None:
if os.environ.get("OPENAI_API_KEY") in {None, "", "test_key"}:
pytest.fail("A real OPENAI_API_KEY is required for the live beta test.")
client = AsyncOpenAI()
direct_text, direct_callers, direct_call_ids = await _run_direct_baseline(client)
sdk_callers: set[str] = set()
sdk_call_ids: set[str] = set()
@function_tool
def get_proposal(ctx: ToolContext[Any], proposal: str) -> dict[str, object]:
metadata = get_hosted_agent_metadata(ctx)
sdk_callers.add(metadata.agent_name if metadata else "/root")
sdk_call_ids.add(ctx.tool_call_id)
return _PROPOSALS[proposal]
model = OpenAIHostedMultiAgentModel(
model="gpt-5.6-sol",
openai_client=client,
config=HostedMultiAgentConfig(max_concurrent_subagents=2),
)
agent = Agent(
name="Hosted proposal coordinator",
instructions=(
"Create two subagents. Assign proposal alpha to one and proposal beta to the other. "
"Each subagent must call get_proposal, then the root must synthesize."
),
model=model,
model_settings=ModelSettings(
store=False,
response_include=["reasoning.encrypted_content"],
),
tools=[get_proposal],
)
result = await Runner.run(
agent,
"Compare proposal alpha and proposal beta.",
run_config=RunConfig(tracing_disabled=True),
max_turns=6,
)
assert direct_text
assert result.final_output
assert len(direct_call_ids) == 2
assert len(sdk_call_ids) == 2
assert all(caller != "/root" for caller in direct_callers)
assert all(caller != "/root" for caller in sdk_callers)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
"""Tests for AsyncSQLiteSession functionality."""
from __future__ import annotations
import json
import tempfile
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import pytest
pytest.importorskip("aiosqlite") # Skip tests if aiosqlite is not installed
from agents import Agent, Runner, TResponseInputItem
from agents.extensions.memory import AsyncSQLiteSession
from agents.memory import SessionSettings
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
pytestmark = pytest.mark.asyncio
@pytest.fixture
def agent() -> Agent:
"""Fixture for a basic agent with a fake model."""
return Agent(name="test", model=FakeModel())
def _item_ids(items: Sequence[TResponseInputItem]) -> list[str]:
result: list[str] = []
for item in items:
item_dict = cast(dict[str, Any], item)
result.append(cast(str, item_dict["id"]))
return result
async def test_async_sqlite_session_basic_flow():
"""Test AsyncSQLiteSession add/get/clear behavior."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_basic.db"
session = AsyncSQLiteSession("async_basic", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items
await session.clear_session()
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_item():
"""Test AsyncSQLiteSession pop_item behavior."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop.db"
session = AsyncSQLiteSession("async_pop", db_path)
assert await session.pop_item() is None
items: list[TResponseInputItem] = [
{"role": "user", "content": "One"},
{"role": "assistant", "content": "Two"},
]
await session.add_items(items)
popped = await session.pop_item()
assert popped == items[-1]
assert await session.get_items() == items[:-1]
await session.close()
async def test_async_sqlite_session_pop_item_skips_corrupt_most_recent():
"""pop_item skips corrupt newest rows and returns the next valid item."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_corrupt.db"
session = AsyncSQLiteSession("async_pop_corrupt", db_path)
valid_item: TResponseInputItem = {"role": "user", "content": "valid"}
await session.add_items([valid_item])
conn = await session._get_connection()
await conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not valid json {{{"),
)
await conn.commit()
assert await session.pop_item() == valid_item
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_item_returns_none_after_dropping_only_corrupt_rows():
"""pop_item removes corrupt rows and returns None when no valid items remain."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_only_corrupt.db"
session = AsyncSQLiteSession("async_pop_only_corrupt", db_path)
conn = await session._get_connection()
await conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not valid json {{{"),
)
await conn.commit()
assert await session.pop_item() is None
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_get_items_limit():
"""Test AsyncSQLiteSession get_items limit handling."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_limit.db"
session = AsyncSQLiteSession("async_limit", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Message 1"},
{"role": "assistant", "content": "Response 1"},
{"role": "user", "content": "Message 2"},
]
await session.add_items(items)
latest = await session.get_items(limit=2)
assert latest == items[-2:]
none = await session.get_items(limit=0)
assert none == []
await session.close()
async def test_async_sqlite_session_session_settings_default():
"""Test that session_settings defaults to empty SessionSettings."""
session = AsyncSQLiteSession("async_default_settings")
assert isinstance(session.session_settings, SessionSettings)
assert session.session_settings.limit is None
await session.close()
async def test_async_sqlite_session_session_settings_constructor():
"""Test passing session_settings via constructor."""
session = AsyncSQLiteSession(
"async_constructor_settings",
session_settings=SessionSettings(limit=5),
)
assert session.session_settings is not None
assert session.session_settings.limit == 5
await session.close()
async def test_async_sqlite_session_get_items_uses_session_settings_limit():
"""Test that get_items uses session_settings.limit as default."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_settings_limit.db"
session = AsyncSQLiteSession(
"async_settings_limit",
db_path,
session_settings=SessionSettings(limit=3),
)
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(5)
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items[-3:]
await session.close()
async def test_async_sqlite_session_explicit_limit_overrides_session_settings():
"""Test that explicit limit parameter overrides session_settings."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_settings_override.db"
session = AsyncSQLiteSession(
"async_settings_override",
db_path,
session_settings=SessionSettings(limit=5),
)
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(10)
]
await session.add_items(items)
retrieved = await session.get_items(limit=2)
assert retrieved == items[-2:]
no_items = await session.get_items(limit=0)
assert no_items == []
await session.close()
async def test_async_sqlite_session_unicode_content():
"""Test AsyncSQLiteSession stores unicode content."""
session = AsyncSQLiteSession("async_unicode")
items: list[TResponseInputItem] = [
{"role": "user", "content": "こんにちは"},
{"role": "assistant", "content": "Привет"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items
await session.close()
async def test_async_sqlite_session_runner_integration(agent: Agent):
"""Test that AsyncSQLiteSession works correctly with the agent Runner."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_runner_integration.db"
session = AsyncSQLiteSession("runner_integration_test", db_path)
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("San Francisco")])
result1 = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
assert result1.final_output == "San Francisco"
agent.model.set_next_output([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
last_input = agent.model.last_turn_args["input"]
assert isinstance(last_input, list)
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
await session.close()
async def test_async_sqlite_session_session_isolation(agent: Agent):
"""Test that different session IDs result in isolated conversation histories."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_isolation.db"
session1 = AsyncSQLiteSession("session_1", db_path)
session2 = AsyncSQLiteSession("session_2", db_path)
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("I like cats.")])
await Runner.run(agent, "I like cats.", session=session1)
agent.model.set_next_output([get_text_message("I like dogs.")])
await Runner.run(agent, "I like dogs.", session=session2)
agent.model.set_next_output([get_text_message("You said you like cats.")])
result = await Runner.run(agent, "What animal did I say I like?", session=session1)
assert "cats" in result.final_output.lower()
assert "dogs" not in result.final_output.lower()
await session1.close()
await session2.close()
async def test_async_sqlite_session_add_empty_items_list():
"""Test that adding an empty list of items is a no-op."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_add_empty.db"
session = AsyncSQLiteSession("add_empty_test", db_path)
assert await session.get_items() == []
await session.add_items([])
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_from_empty_session():
"""Test that pop_item returns None on an empty session."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_empty.db"
session = AsyncSQLiteSession("empty_session", db_path)
popped = await session.pop_item()
assert popped is None
await session.close()
async def test_async_sqlite_session_get_items_with_limit_more_than_available():
"""Test limit behavior when requesting more items than exist."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_limit_more.db"
session = AsyncSQLiteSession("limit_more_test", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "1"},
{"role": "assistant", "content": "2"},
{"role": "user", "content": "3"},
{"role": "assistant", "content": "4"},
]
await session.add_items(items)
retrieved = await session.get_items(limit=10)
assert retrieved == items
await session.close()
async def test_async_sqlite_session_get_items_same_timestamp_consistent_order():
"""Test that items with identical timestamps keep insertion order."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_same_timestamp.db"
session = AsyncSQLiteSession("same_timestamp_test", db_path)
older_item = cast(
TResponseInputItem, {"id": "older_same_ts", "role": "user", "content": "old"}
)
reasoning_item = cast(TResponseInputItem, {"id": "rs_same_ts", "type": "reasoning"})
message_item = cast(
TResponseInputItem,
{"id": "msg_same_ts", "type": "message", "role": "assistant", "content": []},
)
await session.add_items([older_item])
await session.add_items([reasoning_item, message_item])
conn = await session._get_connection()
cursor = await conn.execute(
f"SELECT id, message_data FROM {session.messages_table} WHERE session_id = ?",
(session.session_id,),
)
rows = await cursor.fetchall()
await cursor.close()
id_map: dict[str, int] = {
cast(str, json.loads(message_json)["id"]): cast(int, row_id)
for row_id, message_json in rows
}
shared = datetime(2025, 10, 15, 17, 26, 39, 132483)
shared_str = shared.strftime("%Y-%m-%d %H:%M:%S.%f")
await conn.execute(
f"""
UPDATE {session.messages_table}
SET created_at = ?
WHERE id IN (?, ?, ?)
""",
(
shared_str,
id_map["older_same_ts"],
id_map["rs_same_ts"],
id_map["msg_same_ts"],
),
)
await conn.commit()
retrieved = await session.get_items()
assert _item_ids(retrieved) == ["older_same_ts", "rs_same_ts", "msg_same_ts"]
latest_two = await session.get_items(limit=2)
assert _item_ids(latest_two) == ["rs_same_ts", "msg_same_ts"]
await session.close()
async def test_async_sqlite_session_pop_item_same_timestamp_returns_latest():
"""Test that pop_item returns the newest item when timestamps tie."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_same_timestamp_pop.db"
session = AsyncSQLiteSession("same_timestamp_pop_test", db_path)
reasoning_item = cast(TResponseInputItem, {"id": "rs_pop_same_ts", "type": "reasoning"})
message_item = cast(
TResponseInputItem,
{"id": "msg_pop_same_ts", "type": "message", "role": "assistant", "content": []},
)
await session.add_items([reasoning_item, message_item])
conn = await session._get_connection()
shared = datetime(2025, 10, 15, 17, 26, 39, 132483)
shared_str = shared.strftime("%Y-%m-%d %H:%M:%S.%f")
await conn.execute(
f"UPDATE {session.messages_table} SET created_at = ? WHERE session_id = ?",
(shared_str, session.session_id),
)
await conn.commit()
popped = await session.pop_item()
assert popped is not None
assert cast(dict[str, Any], popped)["id"] == "msg_pop_same_ts"
remaining = await session.get_items()
assert _item_ids(remaining) == ["rs_pop_same_ts"]
await session.close()
@@ -0,0 +1,594 @@
"""
Integration tests for DaprSession with real Dapr sidecar and Redis using testcontainers.
These tests use Docker containers for both Redis and Dapr, with proper networking.
Tests are automatically skipped if dependencies (dapr, testcontainers, docker) are not available.
Run with: pytest tests/extensions/memory/test_dapr_redis_integration.py -v
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import sys
import tempfile
import time
import urllib.request
import docker # type: ignore[import-untyped]
import pytest
from docker.errors import DockerException # type: ignore[import-untyped]
# Skip tests if dependencies are not available
pytest.importorskip("dapr") # Skip tests if Dapr is not installed
pytest.importorskip("testcontainers") # Skip if testcontainers is not installed
if sys.platform == "win32":
pytest.skip(
"Dapr Docker integration tests are not supported on Windows",
allow_module_level=True,
)
if shutil.which("docker") is None:
pytest.skip(
"Docker executable is not available; skipping Dapr integration tests",
allow_module_level=True,
)
try:
client = docker.from_env()
client.ping()
except DockerException:
pytest.skip(
"Docker daemon is not available; skipping Dapr integration tests", allow_module_level=True
)
else:
client.close()
from testcontainers.core.container import DockerContainer # type: ignore[import-untyped]
from testcontainers.core.network import Network # type: ignore[import-untyped]
from testcontainers.core.waiting_utils import wait_for_logs # type: ignore[import-untyped]
from agents import Agent, Runner, TResponseInputItem
from agents.extensions.memory import (
DAPR_CONSISTENCY_EVENTUAL,
DAPR_CONSISTENCY_STRONG,
DaprSession,
)
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
# Docker-backed integration tests should stay on the serial test path.
pytestmark = [pytest.mark.asyncio, pytest.mark.serial]
def wait_for_dapr_health(host: str, port: int, timeout: int = 60) -> bool:
"""
Wait for Dapr sidecar to become healthy by checking the HTTP health endpoint.
Args:
host: The host where Dapr is running
port: The HTTP port (typically 3500)
timeout: Maximum time to wait in seconds
Returns:
True if Dapr becomes healthy, False otherwise
"""
health_url = f"http://{host}:{port}/v1.0/healthz/outbound"
start_time = time.time()
while time.time() - start_time < timeout:
try:
with urllib.request.urlopen(health_url, timeout=5) as response:
if 200 <= response.status < 300:
print(f"✓ Dapr health check passed on {health_url}")
return True
except Exception:
pass
time.sleep(1)
print(f"✗ Dapr health check timed out after {timeout}s on {health_url}")
return False
def wait_for_dapr_component(host: str, port: int, component_name: str, timeout: int = 60) -> bool:
"""Wait for a named component to appear in the Dapr metadata endpoint."""
metadata_url = f"http://{host}:{port}/v1.0/metadata"
start_time = time.time()
while time.time() - start_time < timeout:
try:
with urllib.request.urlopen(metadata_url, timeout=5) as response:
if 200 <= response.status < 300:
payload = json.load(response)
components = payload.get("components", [])
if any(component.get("name") == component_name for component in components):
print(f"✓ Dapr component {component_name} loaded via {metadata_url}")
return True
except Exception:
pass
time.sleep(1)
print(f"✗ Dapr component {component_name} did not load after {timeout}s")
return False
@pytest.fixture(scope="module")
def docker_network():
"""Create a Docker network for container-to-container communication."""
with Network() as network:
yield network
@pytest.fixture(scope="module")
def redis_container(docker_network):
"""Start Redis container on the shared network."""
container = (
DockerContainer("redis:7-alpine")
.with_network(docker_network)
.with_network_aliases("redis")
.with_exposed_ports(6379)
)
container.start()
wait_for_logs(container, "Ready to accept connections", timeout=30)
try:
yield container
finally:
container.stop()
@pytest.fixture(scope="module")
def dapr_container(redis_container, docker_network):
"""Start Dapr sidecar container with Redis state store configuration."""
# Create temporary components directory
temp_dir = tempfile.mkdtemp()
os.chmod(temp_dir, 0o755)
components_path = os.path.join(temp_dir, "components")
os.makedirs(components_path, exist_ok=True)
os.chmod(components_path, 0o755)
# Write Redis state store component configuration
# KEY: Use 'redis:6379' (network alias), NOT localhost!
state_store_config = """
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.redis
version: v1
metadata:
- name: redisHost
value: redis:6379
- name: redisPassword
value: ""
- name: actorStateStore
value: "false"
"""
state_store_path = os.path.join(components_path, "statestore.yaml")
with open(state_store_path, "w") as f:
f.write(state_store_config)
os.chmod(state_store_path, 0o644)
# Create Dapr container
container = DockerContainer("daprio/daprd:latest")
container = container.with_network(docker_network) # Join the same network
container = container.with_volume_mapping(components_path, "/components", mode="ro")
container = container.with_command(
[
"./daprd",
"-app-id",
"test-app",
"-dapr-http-port",
"3500", # HTTP API port for health checks
"-dapr-grpc-port",
"50001",
"-resources-path",
"/components",
"-log-level",
"info",
]
)
container = container.with_exposed_ports(3500, 50001) # Expose both ports
container.start()
# Get the exposed HTTP port and host
http_host = container.get_container_host_ip()
http_port = container.get_exposed_port(3500)
# Wait for Dapr to become healthy
if not wait_for_dapr_health(http_host, http_port, timeout=60):
container.stop()
pytest.fail("Dapr container failed to become healthy")
if not wait_for_dapr_component(http_host, http_port, "statestore", timeout=60):
logs = container.get_wrapped_container().logs().decode("utf-8", errors="replace")
container.stop()
pytest.fail(f"Dapr state store component failed to load.\nContainer logs:\n{logs}")
# Set environment variables for Dapr SDK health checks
# The Dapr SDK checks these when creating a client
os.environ["DAPR_HTTP_PORT"] = str(http_port)
os.environ["DAPR_RUNTIME_HOST"] = http_host
yield container
# Cleanup environment variables
os.environ.pop("DAPR_HTTP_PORT", None)
os.environ.pop("DAPR_RUNTIME_HOST", None)
container.stop()
# Cleanup
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def agent() -> Agent:
"""Fixture for a basic agent with a fake model."""
return Agent(name="test", model=FakeModel())
async def test_dapr_redis_integration(dapr_container, monkeypatch):
"""Test DaprSession with real Dapr sidecar and Redis backend."""
# Get Dapr gRPC address (exposed to host)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
# Monkeypatch the Dapr health check since we already verified it in the fixture
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
# Create session using from_address
session = DaprSession.from_address(
session_id="integration_test_session",
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
# Test connectivity
is_connected = await session.ping()
assert is_connected is True
# Clear any existing data
await session.clear_session()
# Test add_items
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello from integration test"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
# Test get_items
retrieved = await session.get_items()
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Hello from integration test"
assert retrieved[1].get("content") == "Hi there!"
# Test get_items with limit
latest_1 = await session.get_items(limit=1)
assert len(latest_1) == 1
assert latest_1[0].get("content") == "Hi there!"
# Test pop_item
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "Hi there!"
remaining = await session.get_items()
assert len(remaining) == 1
assert remaining[0].get("content") == "Hello from integration test"
# Test clear_session
await session.clear_session()
cleared = await session.get_items()
assert len(cleared) == 0
finally:
await session.close()
async def test_dapr_runner_integration(agent: Agent, dapr_container, monkeypatch):
"""Test DaprSession with agent Runner using real Dapr sidecar."""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
session = DaprSession.from_address(
session_id="runner_integration_test",
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
await session.clear_session()
# First turn
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("San Francisco")])
result1 = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
assert result1.final_output == "San Francisco"
# Second turn - should remember context
agent.model.set_next_output([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
# Verify history
last_input = agent.model.last_turn_args["input"]
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
finally:
await session.close()
async def test_dapr_session_isolation(dapr_container, monkeypatch):
"""Test that different session IDs are isolated with real Dapr."""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
session1 = DaprSession.from_address(
session_id="isolated_session_1",
state_store_name="statestore",
dapr_address=dapr_address,
)
session2 = DaprSession.from_address(
session_id="isolated_session_2",
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
# Clear both sessions
await session1.clear_session()
await session2.clear_session()
# Add different data to each session
await session1.add_items([{"role": "user", "content": "session 1 data"}])
await session2.add_items([{"role": "user", "content": "session 2 data"}])
# Verify isolation
items1 = await session1.get_items()
items2 = await session2.get_items()
assert len(items1) == 1
assert len(items2) == 1
assert items1[0].get("content") == "session 1 data"
assert items2[0].get("content") == "session 2 data"
finally:
await session1.clear_session()
await session2.clear_session()
await session1.close()
await session2.close()
async def test_dapr_ttl_functionality(dapr_container, monkeypatch):
"""Test TTL functionality with real Dapr and Redis (if supported by state store)."""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
# Create session with short TTL
session = DaprSession.from_address(
session_id="ttl_test_session",
state_store_name="statestore",
dapr_address=dapr_address,
ttl=2, # 2 seconds TTL
)
try:
await session.clear_session()
# Add items with TTL
items: list[TResponseInputItem] = [
{"role": "user", "content": "This should expire soon"},
]
await session.add_items(items)
# Verify items exist immediately
retrieved = await session.get_items()
assert len(retrieved) == 1
# Note: Actual expiration testing depends on state store TTL support
# Redis state store supports TTL via ttlInSeconds metadata
finally:
await session.clear_session()
await session.close()
async def test_dapr_consistency_levels(dapr_container, monkeypatch):
"""Test different consistency levels with real Dapr."""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
# Test eventual consistency
session_eventual = DaprSession.from_address(
session_id="eventual_consistency_test",
state_store_name="statestore",
dapr_address=dapr_address,
consistency=DAPR_CONSISTENCY_EVENTUAL,
)
# Test strong consistency
session_strong = DaprSession.from_address(
session_id="strong_consistency_test",
state_store_name="statestore",
dapr_address=dapr_address,
consistency=DAPR_CONSISTENCY_STRONG,
)
try:
await session_eventual.clear_session()
await session_strong.clear_session()
# Both should work correctly
items: list[TResponseInputItem] = [{"role": "user", "content": "Consistency test"}]
await session_eventual.add_items(items)
retrieved_eventual = await session_eventual.get_items()
assert len(retrieved_eventual) == 1
await session_strong.add_items(items)
retrieved_strong = await session_strong.get_items()
assert len(retrieved_strong) == 1
finally:
await session_eventual.clear_session()
await session_strong.clear_session()
await session_eventual.close()
await session_strong.close()
async def test_dapr_unicode_and_special_chars(dapr_container, monkeypatch):
"""Test unicode and special characters with real Dapr and Redis."""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
session = DaprSession.from_address(
session_id="unicode_test_session",
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
await session.clear_session()
# Test unicode content
items: list[TResponseInputItem] = [
{"role": "user", "content": "こんにちは"},
{"role": "assistant", "content": "😊👍"},
{"role": "user", "content": "Привет"},
{"role": "assistant", "content": '{"nested": "json"}'},
{"role": "user", "content": "Line1\nLine2\tTabbed"},
]
await session.add_items(items)
# Retrieve and verify
retrieved = await session.get_items()
assert len(retrieved) == 5
assert retrieved[0].get("content") == "こんにちは"
assert retrieved[1].get("content") == "😊👍"
assert retrieved[2].get("content") == "Привет"
assert retrieved[3].get("content") == '{"nested": "json"}'
assert retrieved[4].get("content") == "Line1\nLine2\tTabbed"
finally:
await session.clear_session()
await session.close()
async def test_dapr_concurrent_writes_resolution(dapr_container, monkeypatch):
"""
Concurrent writes from multiple session instances should resolve via
optimistic concurrency.
"""
from dapr.clients.health import DaprHealth
monkeypatch.setattr(DaprHealth, "wait_until_ready", lambda: None)
dapr_host = dapr_container.get_container_host_ip()
dapr_port = dapr_container.get_exposed_port(50001)
dapr_address = f"{dapr_host}:{dapr_port}"
# Use two different session objects pointing to the same logical session_id
# to create real contention.
session_id = "concurrent_integration_session"
s1 = DaprSession.from_address(
session_id=session_id,
state_store_name="statestore",
dapr_address=dapr_address,
)
s2 = DaprSession.from_address(
session_id=session_id,
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
# Clean slate.
await s1.clear_session()
# Fire multiple parallel add_items calls from two different session instances.
tasks: list[asyncio.Task[None]] = []
for i in range(10):
tasks.append(
asyncio.create_task(
s1.add_items(
[
{"role": "user", "content": f"A-{i}"},
]
)
)
)
tasks.append(
asyncio.create_task(
s2.add_items(
[
{"role": "assistant", "content": f"B-{i}"},
]
)
)
)
await asyncio.gather(*tasks)
# Validate all messages were persisted.
# Use a fresh session object for readback to avoid any local caching
# (none expected, but explicit).
s_read = DaprSession.from_address(
session_id=session_id,
state_store_name="statestore",
dapr_address=dapr_address,
)
try:
items = await s_read.get_items()
contents = [item.get("content") for item in items]
# We expect 20 total messages: A-0..9 and B-0..9 (order unspecified).
assert len(contents) == 20
for i in range(10):
assert f"A-{i}" in contents
assert f"B-{i}" in contents
finally:
await s_read.close()
finally:
await s1.close()
await s2.close()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,576 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import cast
import pytest
pytest.importorskip("cryptography") # Skip tests if cryptography is not installed
from cryptography.fernet import Fernet
from agents import Agent, Runner, SessionSettings, SQLiteSession, TResponseInputItem
from agents.extensions.memory.encrypt_session import EncryptedSession
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
# Mark all tests in this file as asyncio
pytestmark = pytest.mark.asyncio
def _invalid_encrypted_envelope() -> TResponseInputItem:
return cast(
TResponseInputItem,
{"__enc__": 1, "v": 1, "kid": "hkdf-v1", "payload": "not-a-valid-token"},
)
@pytest.fixture
def agent() -> Agent:
"""Fixture for a basic agent with a fake model."""
return Agent(name="test", model=FakeModel())
@pytest.fixture
def encryption_key() -> str:
"""Fixture for a valid Fernet encryption key."""
return str(Fernet.generate_key().decode("utf-8"))
@pytest.fixture
def set_fernet_time(monkeypatch):
"""Freeze Fernet TTL checks so expiration tests avoid real waiting."""
current_time = 1_000
def _set_time(value: int) -> None:
nonlocal current_time
current_time = value
monkeypatch.setattr("cryptography.fernet.time.time", lambda: current_time)
return _set_time
@pytest.fixture
def underlying_session():
"""Fixture for an underlying SQLite session."""
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_encrypt.db"
return SQLiteSession("test_session", db_path)
async def test_encrypted_session_basic_functionality(
agent: Agent, encryption_key: str, underlying_session: SQLiteSession
):
"""Test basic encryption/decryption functionality."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
ttl=600,
)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Hello"
assert retrieved[1].get("content") == "Hi there!"
encrypted_items = await underlying_session.get_items()
assert encrypted_items[0].get("__enc__") == 1
assert "payload" in encrypted_items[0]
assert encrypted_items[0].get("content") != "Hello"
underlying_session.close()
async def test_encrypted_session_with_runner(
agent: Agent, encryption_key: str, underlying_session: SQLiteSession
):
"""Test that EncryptedSession works with Runner."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("San Francisco")])
result1 = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
assert result1.final_output == "San Francisco"
agent.model.set_next_output([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
last_input = agent.model.last_turn_args["input"]
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
underlying_session.close()
async def test_encrypted_session_pop_item(encryption_key: str, underlying_session: SQLiteSession):
"""Test pop_item functionality."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
items: list[TResponseInputItem] = [
{"role": "user", "content": "First"},
{"role": "assistant", "content": "Second"},
]
await session.add_items(items)
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "Second"
remaining = await session.get_items()
assert len(remaining) == 1
assert remaining[0].get("content") == "First"
underlying_session.close()
async def test_encrypted_session_clear(encryption_key: str, underlying_session: SQLiteSession):
"""Test clear_session functionality."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
await session.add_items([{"role": "user", "content": "Test"}])
await session.clear_session()
items = await session.get_items()
assert len(items) == 0
underlying_session.close()
async def test_encrypted_session_ttl_expiration(
encryption_key: str, underlying_session: SQLiteSession, set_fernet_time
):
"""Test TTL expiration - expired items are silently skipped."""
set_fernet_time(1_000)
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
ttl=1, # 1 second TTL
)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]
await session.add_items(items)
set_fernet_time(1_002)
retrieved = await session.get_items()
assert len(retrieved) == 0
underlying_items = await underlying_session.get_items()
assert len(underlying_items) == 2
underlying_session.close()
async def test_encrypted_session_pop_expired(
encryption_key: str, underlying_session: SQLiteSession, set_fernet_time
):
"""Test pop_item with expired data."""
set_fernet_time(1_000)
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
ttl=1,
)
await session.add_items([{"role": "user", "content": "Test"}])
set_fernet_time(1_002)
popped = await session.pop_item()
assert popped is None
underlying_session.close()
async def test_encrypted_session_pop_mixed_expired_valid(
encryption_key: str, underlying_session: SQLiteSession, set_fernet_time
):
"""Test pop_item auto-retry with mixed expired and valid items."""
set_fernet_time(1_000)
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
ttl=2, # 2 second TTL
)
await session.add_items(
[
{"role": "user", "content": "Old message 1"},
{"role": "assistant", "content": "Old response 1"},
]
)
set_fernet_time(1_003)
await session.add_items(
[
{"role": "user", "content": "New message"},
{"role": "assistant", "content": "New response"},
]
)
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "New response"
popped2 = await session.pop_item()
assert popped2 is not None
assert popped2.get("content") == "New message"
popped3 = await session.pop_item()
assert popped3 is None
underlying_session.close()
async def test_encrypted_session_raw_string_key(underlying_session: SQLiteSession):
"""Test using raw string as encryption key (not base64)."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key="my-secret-password", # Raw string, not Fernet key
)
await session.add_items([{"role": "user", "content": "Test"}])
items = await session.get_items()
assert len(items) == 1
assert items[0].get("content") == "Test"
underlying_session.close()
async def test_encrypted_session_get_items_limit(
encryption_key: str, underlying_session: SQLiteSession
):
"""Test get_items with limit parameter."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(5)
]
await session.add_items(items)
limited = await session.get_items(limit=2)
assert len(limited) == 2
assert limited[0].get("content") == "Message 3" # Latest 2
assert limited[1].get("content") == "Message 4"
underlying_session.close()
async def test_encrypted_session_get_items_limit_skips_invalid_latest_envelope(
encryption_key: str, underlying_session: SQLiteSession
):
"""Test that limit counts valid decrypted items, not encrypted envelopes."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
await session.add_items([{"role": "user", "content": "older valid"}])
await underlying_session.add_items([_invalid_encrypted_envelope()])
all_items = await session.get_items()
assert [item.get("content") for item in all_items] == ["older valid"]
limited = await session.get_items(limit=1)
assert [item.get("content") for item in limited] == ["older valid"]
underlying_session.close()
async def test_encrypted_session_get_items_limit_returns_latest_valid_items_after_invalids(
encryption_key: str, underlying_session: SQLiteSession
):
"""Test that invalid envelopes do not hide earlier valid items from limit."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
await session.add_items(
[
{"role": "user", "content": "valid 0"},
{"role": "assistant", "content": "valid 1"},
]
)
await underlying_session.add_items([_invalid_encrypted_envelope()])
await session.add_items([{"role": "user", "content": "valid 2"}])
limited = await session.get_items(limit=2)
assert [item.get("content") for item in limited] == ["valid 1", "valid 2"]
underlying_session.close()
async def test_encrypted_session_get_items_session_settings_limit_skips_invalid_envelopes(
encryption_key: str, underlying_session: SQLiteSession
):
"""Test that session settings limit counts valid decrypted items."""
underlying_session.session_settings = SessionSettings(limit=3)
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
await session.add_items(
[
{"role": "user", "content": "valid 0"},
{"role": "assistant", "content": "valid 1"},
{"role": "user", "content": "valid 2"},
]
)
await underlying_session.add_items([_invalid_encrypted_envelope()])
items = await session.get_items()
assert [item.get("content") for item in items] == ["valid 0", "valid 1", "valid 2"]
underlying_session.close()
async def test_encrypted_session_unicode_content(
encryption_key: str, underlying_session: SQLiteSession
):
"""Test encryption of international text content."""
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello world"},
{"role": "assistant", "content": "Special chars: áéíóú"},
{"role": "user", "content": "Numbers and symbols: 123!@#"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved[0].get("content") == "Hello world"
assert retrieved[1].get("content") == "Special chars: áéíóú"
assert retrieved[2].get("content") == "Numbers and symbols: 123!@#"
underlying_session.close()
class CustomSession(SQLiteSession):
"""Mock custom session with additional methods for testing delegation."""
def get_stats(self) -> dict[str, int]:
"""Custom method that should be accessible through delegation."""
return {"custom_method_calls": 42, "test_value": 123}
async def custom_async_method(self) -> str:
"""Custom async method for testing delegation."""
return "custom_async_result"
async def test_encrypted_session_delegation():
"""Test that custom methods on underlying session are accessible through delegation."""
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_delegation.db"
underlying_session = CustomSession("test_session", db_path)
encryption_key = str(Fernet.generate_key().decode("utf-8"))
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying_session,
encryption_key=encryption_key,
)
stats = session.get_stats()
assert stats == {"custom_method_calls": 42, "test_value": 123}
result = await session.custom_async_method()
assert result == "custom_async_result"
await session.add_items([{"role": "user", "content": "Test delegation"}])
items = await session.get_items()
assert len(items) == 1
assert items[0].get("content") == "Test delegation"
underlying_session.close()
# ============================================================================
# SessionSettings Tests
# ============================================================================
async def test_session_settings_delegated_to_underlying(encryption_key: str):
"""Test that session_settings is correctly delegated to underlying session."""
from agents.memory import SessionSettings
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_settings.db"
underlying = SQLiteSession("test_session", db_path, session_settings=SessionSettings(limit=5))
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying,
encryption_key=encryption_key,
)
# session_settings should be accessible through EncryptedSession
assert session.session_settings is not None
assert session.session_settings.limit == 5
underlying.close()
async def test_session_settings_get_items_uses_underlying_limit(encryption_key: str):
"""Test that get_items uses underlying session's session_settings.limit."""
from agents.memory import SessionSettings
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_settings_limit.db"
underlying = SQLiteSession("test_session", db_path, session_settings=SessionSettings(limit=3))
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying,
encryption_key=encryption_key,
)
# Add 5 items
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(5)
]
await session.add_items(items)
# get_items() with no limit should use underlying session_settings.limit=3
retrieved = await session.get_items()
assert len(retrieved) == 3
# Should get the last 3 items
assert retrieved[0].get("content") == "Message 2"
assert retrieved[1].get("content") == "Message 3"
assert retrieved[2].get("content") == "Message 4"
underlying.close()
async def test_session_settings_explicit_limit_overrides_settings(encryption_key: str):
"""Test that explicit limit parameter overrides session_settings."""
from agents.memory import SessionSettings
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_override.db"
underlying = SQLiteSession("test_session", db_path, session_settings=SessionSettings(limit=5))
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying,
encryption_key=encryption_key,
)
# Add 10 items
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(10)
]
await session.add_items(items)
# Explicit limit=2 should override session_settings.limit=5
retrieved = await session.get_items(limit=2)
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Message 8"
assert retrieved[1].get("content") == "Message 9"
underlying.close()
async def test_session_settings_resolve():
"""Test SessionSettings.resolve() method."""
from agents.memory import SessionSettings
base = SessionSettings(limit=100)
override = SessionSettings(limit=50)
final = base.resolve(override)
assert final.limit == 50 # Override wins
assert base.limit == 100 # Original unchanged
# Resolving with None returns self
final_none = base.resolve(None)
assert final_none.limit == 100
async def test_runner_with_session_settings_override(encryption_key: str):
"""Test that RunConfig can override session's default settings."""
from agents import Agent, RunConfig, Runner
from agents.memory import SessionSettings
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test_runner_override.db"
underlying = SQLiteSession("test_session", db_path, session_settings=SessionSettings(limit=100))
session = EncryptedSession(
session_id="test_session",
underlying_session=underlying,
encryption_key=encryption_key,
)
# Add some history
items: list[TResponseInputItem] = [{"role": "user", "content": f"Turn {i}"} for i in range(10)]
await session.add_items(items)
model = FakeModel()
agent = Agent(name="test", model=model)
model.set_next_output([get_text_message("Got it")])
await Runner.run(
agent,
"New question",
session=session,
run_config=RunConfig(
session_settings=SessionSettings(limit=2) # Override to 2
),
)
# Verify the agent received only the last 2 history items + new question
last_input = model.last_turn_args["input"]
# Filter out the new "New question" input
history_items = [item for item in last_input if item.get("content") != "New question"]
# Should have 2 history items (last two from the 10 we added)
assert len(history_items) == 2
underlying.close()
@@ -0,0 +1,154 @@
from __future__ import annotations
import importlib.abc
import sys
from types import ModuleType
import pytest
_PACKAGE_EXPORTS: tuple[tuple[str, str, str, str, str], ...] = (
(
"EncryptedSession",
"agents.extensions.memory.encrypt_session",
"agents.extensions.memory.encrypt_session",
"cryptography",
"encrypt",
),
("RedisSession", "agents.extensions.memory.redis_session", "redis.asyncio", "redis", "redis"),
(
"SQLAlchemySession",
"agents.extensions.memory.sqlalchemy_session",
"agents.extensions.memory.sqlalchemy_session",
"sqlalchemy",
"sqlalchemy",
),
("DaprSession", "agents.extensions.memory.dapr_session", "dapr.aio.clients", "dapr", "dapr"),
(
"DAPR_CONSISTENCY_EVENTUAL",
"agents.extensions.memory.dapr_session",
"dapr.aio.clients",
"dapr",
"dapr",
),
(
"DAPR_CONSISTENCY_STRONG",
"agents.extensions.memory.dapr_session",
"dapr.aio.clients",
"dapr",
"dapr",
),
(
"MongoDBSession",
"agents.extensions.memory.mongodb_session",
"pymongo.asynchronous.collection",
"mongodb",
"mongodb",
),
)
_DIRECT_MODULE_IMPORTS: tuple[tuple[str, str, str, str], ...] = (
("agents.extensions.memory.redis_session", "redis.asyncio", "redis", "redis"),
("agents.extensions.memory.dapr_session", "dapr.aio.clients", "dapr", "dapr"),
(
"agents.extensions.memory.mongodb_session",
"pymongo.asynchronous.collection",
"mongodb",
"mongodb",
),
)
class _BrokenImportFinder(importlib.abc.MetaPathFinder):
def __init__(self, broken_module: str, error_cls: type[ImportError]) -> None:
self._broken_module = broken_module
self._error_cls = error_cls
def find_spec(
self,
fullname: str,
path: object | None,
target: ModuleType | None = None,
) -> None:
if fullname == self._broken_module:
raise self._error_cls("simulated dependency import failure")
return None
def _reset_package_imports(
monkeypatch: pytest.MonkeyPatch,
memory_module: ModuleType,
symbol: str,
module_name: str,
broken_module: str,
) -> None:
monkeypatch.delitem(memory_module.__dict__, symbol, raising=False)
_reset_loaded_module(monkeypatch, module_name)
_reset_loaded_module(monkeypatch, broken_module)
def _reset_loaded_module(monkeypatch: pytest.MonkeyPatch, module_name: str) -> None:
monkeypatch.delitem(sys.modules, module_name, raising=False)
parent_name, short_name = module_name.rsplit(".", 1)
parent_module = sys.modules.get(parent_name)
if parent_module is not None:
monkeypatch.delitem(parent_module.__dict__, short_name, raising=False)
def _reset_module_imports(
monkeypatch: pytest.MonkeyPatch,
module_name: str,
broken_module: str,
) -> None:
_reset_loaded_module(monkeypatch, module_name)
_reset_loaded_module(monkeypatch, broken_module)
@pytest.mark.parametrize(
("symbol", "module_name", "broken_module", "dependency_name", "extra_name"),
_PACKAGE_EXPORTS,
)
def test_memory_package_imports_point_to_optional_extra(
monkeypatch: pytest.MonkeyPatch,
symbol: str,
module_name: str,
broken_module: str,
dependency_name: str,
extra_name: str,
) -> None:
import agents.extensions.memory as memory_module
_reset_package_imports(monkeypatch, memory_module, symbol, module_name, broken_module)
finder = _BrokenImportFinder(broken_module, ModuleNotFoundError)
monkeypatch.setattr(sys, "meta_path", [finder, *sys.meta_path])
with pytest.raises(ImportError) as exc_info:
getattr(memory_module, symbol)
assert f"requires the '{dependency_name}' extra" in str(exc_info.value)
assert f"openai-agents[{extra_name}]" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, ImportError)
@pytest.mark.parametrize(
("module_name", "broken_module", "dependency_name", "extra_name"),
_DIRECT_MODULE_IMPORTS,
)
@pytest.mark.parametrize("error_cls", [ImportError, ModuleNotFoundError])
def test_memory_direct_module_imports_point_to_optional_extra(
monkeypatch: pytest.MonkeyPatch,
module_name: str,
broken_module: str,
dependency_name: str,
extra_name: str,
error_cls: type[ImportError],
) -> None:
_reset_module_imports(monkeypatch, module_name, broken_module)
finder = _BrokenImportFinder(broken_module, error_cls)
monkeypatch.setattr(sys, "meta_path", [finder, *sys.meta_path])
with pytest.raises(ImportError) as exc_info:
__import__(module_name)
assert f"requires the '{dependency_name}' extra" in str(exc_info.value)
assert f"openai-agents[{extra_name}]" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, ImportError)
@@ -0,0 +1,831 @@
"""Tests for MongoDBSession using in-process mock objects.
All tests run without a real MongoDB server — or even the ``pymongo``
package — by injecting lightweight fake classes into ``sys.modules``
before the module under test is imported. This keeps the suite fast and
dependency-free while exercising the full session logic.
"""
from __future__ import annotations
import sys
import types
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any
from unittest.mock import patch
import pytest
from agents import Agent, Runner, TResponseInputItem
from agents.memory.session_settings import SessionSettings
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# In-memory fake pymongo async types
# ---------------------------------------------------------------------------
class FakeObjectId:
"""Minimal ObjectId stand-in with a monotonic counter for sort order."""
_counter = 0
def __init__(self) -> None:
FakeObjectId._counter += 1
self._value = FakeObjectId._counter
def __lt__(self, other: FakeObjectId) -> bool:
return self._value < other._value
def __repr__(self) -> str:
return f"FakeObjectId({self._value})"
class FakeCursor:
"""Minimal async cursor returned by ``find()``."""
def __init__(self, docs: list[dict[str, Any]]) -> None:
self._docs = docs
def sort(
self,
key: str | list[tuple[str, int]],
direction: int | None = None,
) -> FakeCursor:
if isinstance(key, list):
pairs = key
else:
direction = direction if direction is not None else 1
pairs = [(key, direction)]
docs = list(self._docs)
for field, dir_ in reversed(pairs):
docs.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1))
self._docs = docs
return self
def limit(self, n: int) -> FakeCursor:
self._docs = self._docs[:n]
return self
async def to_list(self) -> list[dict[str, Any]]:
return list(self._docs)
class FakeAsyncCollection:
"""In-memory substitute for pymongo AsyncCollection."""
def __init__(self) -> None:
self._docs: dict[Any, dict[str, Any]] = {}
async def create_index(self, keys: Any, **kwargs: Any) -> str:
return "fake_index"
def find(self, query: dict[str, Any] | None = None) -> FakeCursor:
query = query or {}
results = [doc for doc in self._docs.values() if self._matches(doc, query)]
return FakeCursor(results)
async def find_one_and_delete(
self,
query: dict[str, Any],
sort: list[tuple[str, int]] | None = None,
) -> dict[str, Any] | None:
matches = [doc for doc in self._docs.values() if self._matches(doc, query)]
if not matches:
return None
if sort:
field, dir_ = sort[0]
matches.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1))
doc = matches[0]
self._docs.pop(id(doc["_id"]))
return doc
async def insert_many(
self,
documents: list[dict[str, Any]],
ordered: bool = True,
) -> Any:
for doc in documents:
if "_id" not in doc:
doc["_id"] = FakeObjectId()
self._docs[id(doc["_id"])] = dict(doc)
async def find_one_and_update(
self,
query: dict[str, Any],
update: dict[str, Any],
upsert: bool = False,
return_document: bool = False,
) -> dict[str, Any] | None:
for doc in self._docs.values():
if self._matches(doc, query):
# Apply $inc fields.
for field, delta in update.get("$inc", {}).items():
doc[field] = doc.get(field, 0) + delta
for field, value in update.get("$set", {}).items():
doc[field] = value
return dict(doc) if return_document else None
if upsert:
new_doc: dict[str, Any] = {"_id": FakeObjectId()}
new_doc.update(update.get("$setOnInsert", {}))
new_doc.update(update.get("$set", {}))
for field, delta in update.get("$inc", {}).items():
new_doc[field] = new_doc.get(field, 0) + delta
self._docs[id(new_doc["_id"])] = new_doc
return dict(new_doc) if return_document else None
return None
async def update_one(
self,
query: dict[str, Any],
update: dict[str, Any],
upsert: bool = False,
) -> None:
for doc in self._docs.values():
if self._matches(doc, query):
return # Exists — $setOnInsert is a no-op on existing docs.
if upsert:
new_doc2: dict[str, Any] = {"_id": FakeObjectId()}
new_doc2.update(update.get("$setOnInsert", {}))
self._docs[id(new_doc2["_id"])] = new_doc2
async def delete_many(self, query: dict[str, Any]) -> None:
to_remove = [k for k, d in self._docs.items() if self._matches(d, query)]
for key in to_remove:
del self._docs[key]
async def delete_one(self, query: dict[str, Any]) -> None:
for key, doc in list(self._docs.items()):
if self._matches(doc, query):
del self._docs[key]
return
@staticmethod
def _matches(doc: dict[str, Any], query: dict[str, Any]) -> bool:
return all(doc.get(k) == v for k, v in query.items())
class FakeAsyncDatabase:
"""In-memory substitute for a pymongo async Database."""
def __init__(self) -> None:
self._collections: dict[str, FakeAsyncCollection] = defaultdict(FakeAsyncCollection)
def __getitem__(self, name: str) -> FakeAsyncCollection:
return self._collections[name]
class FakeAdminDatabase:
"""Minimal admin database used by ping()."""
def __init__(self) -> None:
self._closed = False
async def command(self, cmd: str) -> dict[str, Any]:
if self._closed:
raise ConnectionError("Client is closed.")
return {"ok": 1}
class FakeDriverInfo:
"""Minimal stand-in for pymongo.driver_info.DriverInfo."""
def __init__(self, name: str, version: str | None = None) -> None:
self.name = name
self.version = version
class FakeAsyncMongoClient:
"""In-memory substitute for pymongo AsyncMongoClient."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._databases: dict[str, FakeAsyncDatabase] = defaultdict(FakeAsyncDatabase)
self._closed = False
self.admin = FakeAdminDatabase()
self._metadata_calls: list[FakeDriverInfo] = []
def __getitem__(self, name: str) -> FakeAsyncDatabase:
return self._databases[name]
def append_metadata(self, driver_info: FakeDriverInfo) -> None:
"""Record append_metadata calls for test assertions."""
self._metadata_calls.append(driver_info)
async def close(self) -> None:
"""Async close — matches PyMongo's AsyncMongoClient.close() signature."""
self._closed = True
self.admin._closed = True
# ---------------------------------------------------------------------------
# Inject fake pymongo into sys.modules before importing the module under test
# ---------------------------------------------------------------------------
def _make_fake_pymongo_modules() -> None:
"""Populate sys.modules with stub pymongo async modules."""
pymongo_mod = sys.modules.get("pymongo") or types.ModuleType("pymongo")
async_pkg = types.ModuleType("pymongo.asynchronous")
collection_mod = types.ModuleType("pymongo.asynchronous.collection")
client_mod = types.ModuleType("pymongo.asynchronous.mongo_client")
driver_info_mod = types.ModuleType("pymongo.driver_info")
collection_mod.AsyncCollection = FakeAsyncCollection # type: ignore[attr-defined]
client_mod.AsyncMongoClient = FakeAsyncMongoClient # type: ignore[attr-defined]
driver_info_mod.DriverInfo = FakeDriverInfo # type: ignore[attr-defined]
sys.modules["pymongo"] = pymongo_mod
sys.modules["pymongo.asynchronous"] = async_pkg
sys.modules["pymongo.asynchronous.collection"] = collection_mod
sys.modules["pymongo.asynchronous.mongo_client"] = client_mod
sys.modules["pymongo.driver_info"] = driver_info_mod
_make_fake_pymongo_modules()
# Now it's safe to import the module under test.
from agents.extensions.memory.mongodb_session import MongoDBSession # noqa: E402
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_session(session_id: str = "test-session", **kwargs: Any) -> MongoDBSession:
"""Create a MongoDBSession backed by a FakeAsyncMongoClient."""
client = FakeAsyncMongoClient()
MongoDBSession._init_state.clear()
return MongoDBSession(
session_id,
client=client, # type: ignore[arg-type]
database="agents_test",
**kwargs,
)
@pytest.fixture
def session() -> MongoDBSession:
return _make_session()
@pytest.fixture
def agent() -> Agent:
return Agent(name="test", model=FakeModel())
# ---------------------------------------------------------------------------
# Core CRUD tests
# ---------------------------------------------------------------------------
async def test_add_and_get_items(session: MongoDBSession) -> None:
"""Items added to the session are retrievable in insertion order."""
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Hello"
assert retrieved[1].get("content") == "Hi there!"
async def test_add_empty_list_is_noop(session: MongoDBSession) -> None:
"""Adding an empty list must not create any documents."""
await session.add_items([])
assert await session.get_items() == []
async def test_get_items_empty_session(session: MongoDBSession) -> None:
"""Retrieving items from a brand-new session returns an empty list."""
assert await session.get_items() == []
async def test_pop_item_returns_last(session: MongoDBSession) -> None:
"""pop_item must return and remove the most recently added item."""
items: list[TResponseInputItem] = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "second"},
]
await session.add_items(items)
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "second"
remaining = await session.get_items()
assert len(remaining) == 1
assert remaining[0].get("content") == "first"
async def test_pop_item_empty_session(session: MongoDBSession) -> None:
"""pop_item on an empty session must return None."""
assert await session.pop_item() is None
async def test_clear_session(session: MongoDBSession) -> None:
"""clear_session must remove all items and session metadata."""
await session.add_items([{"role": "user", "content": "x"}])
await session.clear_session()
assert await session.get_items() == []
async def test_multiple_add_calls_accumulate(session: MongoDBSession) -> None:
"""Items from separate add_items calls all appear in get_items."""
await session.add_items([{"role": "user", "content": "a"}])
await session.add_items([{"role": "assistant", "content": "b"}])
await session.add_items([{"role": "user", "content": "c"}])
items = await session.get_items()
assert [i.get("content") for i in items] == ["a", "b", "c"]
async def test_session_metadata_timestamps_are_written(session: MongoDBSession) -> None:
"""Session metadata records creation time and last update time."""
created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
updated_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
with patch("agents.extensions.memory.mongodb_session.datetime") as mocked_datetime:
mocked_datetime.now.side_effect = [created_at, updated_at]
await session.add_items([{"role": "user", "content": "first"}])
session_doc: dict[str, Any] = next(iter(session._sessions._docs.values()))
assert session_doc["session_id"] == session.session_id
assert session_doc["created_at"] == created_at
assert session_doc["updated_at"] == created_at
await session.add_items([{"role": "assistant", "content": "second"}])
assert session_doc["created_at"] == created_at
assert session_doc["updated_at"] == updated_at
assert session_doc["_seq"] == 2
# ---------------------------------------------------------------------------
# Limit / SessionSettings tests
# ---------------------------------------------------------------------------
async def test_get_items_with_explicit_limit(session: MongoDBSession) -> None:
"""Explicit limit returns the N most recent items in chronological order."""
await session.add_items([{"role": "user", "content": str(i)} for i in range(6)])
result = await session.get_items(limit=3)
assert len(result) == 3
assert [r.get("content") for r in result] == ["3", "4", "5"]
async def test_get_items_limit_zero(session: MongoDBSession) -> None:
"""A limit of 0 must return an empty list immediately."""
await session.add_items([{"role": "user", "content": "x"}])
assert await session.get_items(limit=0) == []
async def test_get_items_limit_exceeds_count(session: MongoDBSession) -> None:
"""Requesting more items than exist returns all items without error."""
await session.add_items([{"role": "user", "content": "only"}])
result = await session.get_items(limit=100)
assert len(result) == 1
async def test_session_settings_limit_used_as_default() -> None:
"""session_settings.limit is applied when no explicit limit is given."""
MongoDBSession._init_state.clear()
s = MongoDBSession(
"ls-test",
client=FakeAsyncMongoClient(), # type: ignore[arg-type]
database="agents_test",
session_settings=SessionSettings(limit=2),
)
await s.add_items([{"role": "user", "content": str(i)} for i in range(5)])
result = await s.get_items()
assert len(result) == 2
assert result[0].get("content") == "3"
assert result[1].get("content") == "4"
async def test_explicit_limit_overrides_session_settings() -> None:
"""An explicit limit passed to get_items must override session_settings.limit."""
MongoDBSession._init_state.clear()
s = MongoDBSession(
"override-test",
client=FakeAsyncMongoClient(), # type: ignore[arg-type]
database="agents_test",
session_settings=SessionSettings(limit=10),
)
await s.add_items([{"role": "user", "content": str(i)} for i in range(8)])
result = await s.get_items(limit=2)
assert len(result) == 2
assert result[0].get("content") == "6"
assert result[1].get("content") == "7"
# ---------------------------------------------------------------------------
# Session isolation
# ---------------------------------------------------------------------------
async def test_sessions_are_isolated() -> None:
"""Two sessions with different IDs must not share data."""
MongoDBSession._init_state.clear()
client = FakeAsyncMongoClient()
s1 = MongoDBSession("alice", client=client, database="agents_test") # type: ignore[arg-type]
s2 = MongoDBSession("bob", client=client, database="agents_test") # type: ignore[arg-type]
await s1.add_items([{"role": "user", "content": "alice msg"}])
await s2.add_items([{"role": "user", "content": "bob msg"}])
assert [i.get("content") for i in await s1.get_items()] == ["alice msg"]
assert [i.get("content") for i in await s2.get_items()] == ["bob msg"]
async def test_clear_does_not_affect_other_sessions() -> None:
"""Clearing one session must leave sibling sessions untouched."""
MongoDBSession._init_state.clear()
client = FakeAsyncMongoClient()
s1 = MongoDBSession("s1", client=client, database="agents_test") # type: ignore[arg-type]
s2 = MongoDBSession("s2", client=client, database="agents_test") # type: ignore[arg-type]
await s1.add_items([{"role": "user", "content": "keep"}])
await s2.add_items([{"role": "user", "content": "delete"}])
await s2.clear_session()
assert len(await s1.get_items()) == 1
assert await s2.get_items() == []
# ---------------------------------------------------------------------------
# Serialisation / unicode safety
# ---------------------------------------------------------------------------
async def test_unicode_content_roundtrip(session: MongoDBSession) -> None:
"""Unicode and emoji content must survive the serialisation round-trip."""
items: list[TResponseInputItem] = [
{"role": "user", "content": "こんにちは"},
{"role": "assistant", "content": "😊👍"},
{"role": "user", "content": "Привет"},
]
await session.add_items(items)
result = await session.get_items()
assert result[0].get("content") == "こんにちは"
assert result[1].get("content") == "😊👍"
assert result[2].get("content") == "Привет"
async def test_json_special_characters(session: MongoDBSession) -> None:
"""Items containing JSON-special strings must be stored without corruption."""
items: list[TResponseInputItem] = [
{"role": "user", "content": '{"nested": "value"}'},
{"role": "assistant", "content": 'Quote: "Hello"'},
{"role": "user", "content": "Line1\nLine2\tTabbed"},
]
await session.add_items(items)
result = await session.get_items()
assert result[0].get("content") == '{"nested": "value"}'
assert result[1].get("content") == 'Quote: "Hello"'
assert result[2].get("content") == "Line1\nLine2\tTabbed"
async def test_corrupted_document_is_skipped(session: MongoDBSession) -> None:
"""Documents with invalid JSON in message_data are silently skipped."""
await session.add_items([{"role": "user", "content": "valid"}])
# Inject a corrupted document directly into the fake collection.
bad_doc = {
"_id": FakeObjectId(),
"session_id": session.session_id,
"message_data": "not valid json {{{",
}
session._messages._docs[id(bad_doc["_id"])] = bad_doc
items = await session.get_items()
assert len(items) == 1
assert items[0].get("content") == "valid"
async def test_missing_message_data_field_is_skipped(session: MongoDBSession) -> None:
"""Documents without a message_data field are silently skipped."""
await session.add_items([{"role": "user", "content": "valid"}])
bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id}
session._messages._docs[id(bad_doc["_id"])] = bad_doc
items = await session.get_items()
assert len(items) == 1
async def test_non_string_message_data_is_skipped(session: MongoDBSession) -> None:
"""Documents whose message_data is a non-string BSON type are silently skipped."""
await session.add_items([{"role": "user", "content": "valid"}])
# Inject a document where message_data is an integer — json.loads raises TypeError.
bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id, "message_data": 42}
session._messages._docs[id(bad_doc["_id"])] = bad_doc
items = await session.get_items()
assert len(items) == 1
assert items[0].get("content") == "valid"
async def test_pop_item_skips_corrupt_most_recent(session: MongoDBSession) -> None:
"""pop_item must skip a corrupt most-recent document and return the next valid one."""
await session.add_items([{"role": "user", "content": "valid"}])
# Inject a corrupt document with a higher seq so it sorts as "most recent".
bad_doc = {
"_id": FakeObjectId(),
"session_id": session.session_id,
"seq": 999,
"message_data": "not valid json {{{",
}
session._messages._docs[id(bad_doc["_id"])] = bad_doc
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "valid"
# Both the corrupt doc and the valid one are now gone.
assert await session.get_items() == []
async def test_pop_item_returns_none_when_only_corrupt_docs_remain(
session: MongoDBSession,
) -> None:
"""pop_item must drop every corrupt doc and return None when nothing valid remains."""
bad1 = {
"_id": FakeObjectId(),
"session_id": session.session_id,
"seq": 1,
"message_data": "garbage",
}
bad2 = {
"_id": FakeObjectId(),
"session_id": session.session_id,
"seq": 2,
"message_data": 42, # non-string — TypeError
}
session._messages._docs[id(bad1["_id"])] = bad1
session._messages._docs[id(bad2["_id"])] = bad2
assert await session.pop_item() is None
# Both corrupt docs must have been removed in the process.
assert session._messages._docs == {}
# ---------------------------------------------------------------------------
# Index initialisation (idempotency)
# ---------------------------------------------------------------------------
async def test_index_creation_runs_only_once(session: MongoDBSession) -> None:
"""_ensure_indexes must call create_index only on the very first call."""
call_count = 0
original_messages = session._messages.create_index
original_sessions = session._sessions.create_index
async def counting(*args: Any, **kwargs: Any) -> str:
nonlocal call_count
call_count += 1
return "fake_index"
session._messages.create_index = counting # type: ignore[method-assign]
session._sessions.create_index = counting # type: ignore[method-assign]
await session._ensure_indexes()
await session._ensure_indexes() # Second call must be a no-op.
# Exactly one call per collection (sessions + messages).
assert call_count == 2
session._messages.create_index = original_messages # type: ignore[method-assign]
session._sessions.create_index = original_sessions # type: ignore[method-assign]
async def test_different_clients_each_run_index_init() -> None:
"""Each distinct AsyncMongoClient gets its own index-creation pass."""
MongoDBSession._init_state.clear()
client_a = FakeAsyncMongoClient()
client_b = FakeAsyncMongoClient()
call_counts: dict[str, int] = {"a": 0, "b": 0}
async def counting_a(*args: Any, **kwargs: Any) -> str:
call_counts["a"] += 1
return "fake_index"
async def counting_b(*args: Any, **kwargs: Any) -> str:
call_counts["b"] += 1
return "fake_index"
s_a = MongoDBSession("x", client=client_a, database="agents_test") # type: ignore[arg-type]
s_b = MongoDBSession("x", client=client_b, database="agents_test") # type: ignore[arg-type]
s_a._messages.create_index = counting_a # type: ignore[method-assign]
s_a._sessions.create_index = counting_a # type: ignore[method-assign]
s_b._messages.create_index = counting_b # type: ignore[method-assign]
s_b._sessions.create_index = counting_b # type: ignore[method-assign]
await s_a._ensure_indexes()
await s_b._ensure_indexes()
# Each client must trigger its own index creation (2 calls = sessions + messages).
assert call_counts["a"] == 2
assert call_counts["b"] == 2
# ---------------------------------------------------------------------------
# Connectivity and lifecycle
# ---------------------------------------------------------------------------
async def test_ping_success(session: MongoDBSession) -> None:
"""ping() must return True when the client responds normally."""
assert await session.ping() is True
async def test_ping_failure(session: MongoDBSession) -> None:
"""ping() must return False when the server raises an exception."""
original = session._client.admin.command
async def _fail(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise ConnectionError("unreachable")
session._client.admin.command = _fail # type: ignore[method-assign, assignment]
assert await session.ping() is False
session._client.admin.command = original # type: ignore[method-assign]
async def test_close_external_client_not_closed() -> None:
"""close() must NOT close a client that was injected externally."""
MongoDBSession._init_state.clear()
client = FakeAsyncMongoClient()
s = MongoDBSession("x", client=client, database="agents_test") # type: ignore[arg-type]
assert s._owns_client is False
await s.close()
assert not client._closed
async def test_close_owned_client_is_closed() -> None:
"""close() must close a client created by from_uri."""
MongoDBSession._init_state.clear()
fake_client = FakeAsyncMongoClient()
with patch(
"agents.extensions.memory.mongodb_session.AsyncMongoClient",
return_value=fake_client,
):
s = MongoDBSession.from_uri("owned", uri="mongodb://localhost:27017", database="t")
assert s._owns_client is True
await s.close()
assert fake_client._closed
# ---------------------------------------------------------------------------
# Runner integration
# ---------------------------------------------------------------------------
async def test_runner_integration(agent: Agent) -> None:
"""MongoDBSession must supply conversation history to the Runner."""
session = _make_session("runner-test")
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("San Francisco")])
result1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=session)
assert result1.final_output == "San Francisco"
agent.model.set_next_output([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
last_input = agent.model.last_turn_args["input"]
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
async def test_runner_session_isolation(agent: Agent) -> None:
"""Two independent sessions must not bleed history into each other."""
MongoDBSession._init_state.clear()
client = FakeAsyncMongoClient()
s1 = MongoDBSession("user-a", client=client, database="agents_test") # type: ignore[arg-type]
s2 = MongoDBSession("user-b", client=client, database="agents_test") # type: ignore[arg-type]
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("I like cats.")])
await Runner.run(agent, "I like cats.", session=s1)
agent.model.set_next_output([get_text_message("I like dogs.")])
await Runner.run(agent, "I like dogs.", session=s2)
agent.model.set_next_output([get_text_message("You said you like cats.")])
result = await Runner.run(agent, "What animal did I mention?", session=s1)
assert "cats" in result.final_output.lower()
assert "dogs" not in result.final_output.lower()
async def test_runner_with_session_settings_limit(agent: Agent) -> None:
"""RunConfig.session_settings.limit must cap the history sent to the model."""
from agents import RunConfig
MongoDBSession._init_state.clear()
session = MongoDBSession(
"limit-test",
client=FakeAsyncMongoClient(), # type: ignore[arg-type]
database="agents_test",
session_settings=SessionSettings(limit=100),
)
history: list[TResponseInputItem] = [
{"role": "user", "content": f"Turn {i}"} for i in range(10)
]
await session.add_items(history)
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("Got it")])
await Runner.run(
agent,
"New question",
session=session,
run_config=RunConfig(session_settings=SessionSettings(limit=2)),
)
last_input = agent.model.last_turn_args["input"]
history_items = [i for i in last_input if i.get("content") != "New question"]
assert len(history_items) == 2
# ---------------------------------------------------------------------------
# Client metadata (driver handshake)
# ---------------------------------------------------------------------------
async def test_injected_client_receives_append_metadata() -> None:
"""Append_metadata is called on a caller-supplied client."""
MongoDBSession._init_state.clear()
client = FakeAsyncMongoClient()
MongoDBSession("meta-test", client=client, database="agents_test") # type: ignore[arg-type]
assert len(client._metadata_calls) == 1
info = client._metadata_calls[0]
assert info.name == "openai-agents"
async def test_from_uri_passes_driver_info_to_constructor() -> None:
"""driver=_DRIVER_INFO is forwarded to AsyncMongoClient via from_uri."""
MongoDBSession._init_state.clear()
captured_kwargs: dict[str, Any] = {}
def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient:
captured_kwargs.update(kwargs)
return FakeAsyncMongoClient()
with patch(
"agents.extensions.memory.mongodb_session.AsyncMongoClient",
side_effect=_fake_client,
):
MongoDBSession.from_uri("uri-test", uri="mongodb://localhost:27017", database="t")
assert "driver" in captured_kwargs
assert captured_kwargs["driver"].name == "openai-agents"
async def test_caller_supplied_driver_info_is_not_overwritten() -> None:
"""A caller-supplied driver kwarg must not be silently replaced."""
MongoDBSession._init_state.clear()
captured_kwargs: dict[str, Any] = {}
custom_info = FakeDriverInfo(name="MyApp")
def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient:
captured_kwargs.update(kwargs)
return FakeAsyncMongoClient()
with patch(
"agents.extensions.memory.mongodb_session.AsyncMongoClient",
side_effect=_fake_client,
):
MongoDBSession.from_uri(
"uri-test",
uri="mongodb://localhost:27017",
database="t",
client_kwargs={"driver": custom_info},
)
# The caller's value must be preserved — setdefault must not overwrite it.
assert captured_kwargs["driver"] is custom_info
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,956 @@
from __future__ import annotations
import asyncio
import json
import threading
from collections.abc import Iterable, Sequence
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, cast
import pytest
from openai.types.responses.response_output_message_param import ResponseOutputMessageParam
from openai.types.responses.response_output_text_param import ResponseOutputTextParam
from openai.types.responses.response_reasoning_item_param import (
ResponseReasoningItemParam,
Summary,
)
from sqlalchemy import insert, select, text, update
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.sql import Select
pytest.importorskip("sqlalchemy") # Skip tests if SQLAlchemy is not installed
from agents import Agent, Runner, TResponseInputItem
from agents.extensions.memory.sqlalchemy_session import SQLAlchemySession
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
# Mark all tests in this file as asyncio
pytestmark = pytest.mark.asyncio
# Use in-memory SQLite for tests
DB_URL = "sqlite+aiosqlite:///:memory:"
def _make_message_item(item_id: str, text_value: str) -> TResponseInputItem:
content: ResponseOutputTextParam = {
"type": "output_text",
"text": text_value,
"annotations": [],
"logprobs": [],
}
message: ResponseOutputMessageParam = {
"id": item_id,
"type": "message",
"role": "assistant",
"status": "completed",
"content": [content],
}
return cast(TResponseInputItem, message)
def _make_reasoning_item(item_id: str, summary_text: str) -> TResponseInputItem:
summary: Summary = {"type": "summary_text", "text": summary_text}
reasoning: ResponseReasoningItemParam = {
"id": item_id,
"type": "reasoning",
"summary": [summary],
}
return cast(TResponseInputItem, reasoning)
def _item_ids(items: Sequence[TResponseInputItem]) -> list[str]:
result: list[str] = []
for item in items:
item_dict = cast(dict[str, Any], item)
result.append(cast(str, item_dict["id"]))
return result
@pytest.fixture
def agent() -> Agent:
"""Fixture for a basic agent with a fake model."""
return Agent(name="test", model=FakeModel())
async def test_sqlalchemy_session_direct_ops(agent: Agent):
"""Test direct database operations of SQLAlchemySession."""
session_id = "direct_ops_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
# 1. Add items
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
# 2. Get items and verify
retrieved = await session.get_items()
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Hello"
assert retrieved[1].get("content") == "Hi there!"
# 3. Pop item
popped = await session.pop_item()
assert popped is not None
assert popped.get("content") == "Hi there!"
retrieved_after_pop = await session.get_items()
assert len(retrieved_after_pop) == 1
assert retrieved_after_pop[0].get("content") == "Hello"
# 4. Clear session
await session.clear_session()
retrieved_after_clear = await session.get_items()
assert len(retrieved_after_clear) == 0
async def test_sqlalchemy_session_defaults_to_escaped_non_ascii_storage():
"""Default storage keeps the historical escaped non-ASCII JSON representation."""
session = SQLAlchemySession.from_url("default_ascii_storage", url=DB_URL, create_tables=True)
item: TResponseInputItem = {"role": "user", "content": "café"}
await session.add_items([item])
async with session._session_factory() as sess:
rows = await sess.execute(
select(session._messages.c.message_data).where(
session._messages.c.session_id == session.session_id
)
)
stored = rows.scalar_one()
assert "\\u00e9" in stored
assert "café" not in stored
assert await session.get_items() == [item]
async def test_sqlalchemy_session_can_store_non_ascii_without_escaping():
"""ensure_ascii=False stores multilingual content readably while preserving round-trip data."""
session = SQLAlchemySession.from_url(
"non_ascii_storage",
url=DB_URL,
create_tables=True,
ensure_ascii=False,
)
item: TResponseInputItem = {"role": "user", "content": "café"}
await session.add_items([item])
async with session._session_factory() as sess:
rows = await sess.execute(
select(session._messages.c.message_data).where(
session._messages.c.session_id == session.session_id
)
)
stored = rows.scalar_one()
assert "café" in stored
assert "\\u00e9" not in stored
assert await session.get_items() == [item]
async def test_runner_integration(agent: Agent):
"""Test that SQLAlchemySession works correctly with the agent Runner."""
session_id = "runner_integration_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
# First turn
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("San Francisco")])
result1 = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
assert result1.final_output == "San Francisco"
# Second turn
agent.model.set_next_output([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
# Verify history was passed to the model on the second turn
last_input = agent.model.last_turn_args["input"]
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
async def test_session_isolation(agent: Agent):
"""Test that different session IDs result in isolated conversation histories."""
session_id_1 = "session_1"
session1 = SQLAlchemySession.from_url(session_id_1, url=DB_URL, create_tables=True)
session_id_2 = "session_2"
session2 = SQLAlchemySession.from_url(session_id_2, url=DB_URL, create_tables=True)
# Interact with session 1
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("I like cats.")])
await Runner.run(agent, "I like cats.", session=session1)
# Interact with session 2
agent.model.set_next_output([get_text_message("I like dogs.")])
await Runner.run(agent, "I like dogs.", session=session2)
# Go back to session 1 and check its memory
agent.model.set_next_output([get_text_message("You said you like cats.")])
result = await Runner.run(agent, "What animal did I say I like?", session=session1)
assert "cats" in result.final_output.lower()
assert "dogs" not in result.final_output.lower()
async def test_get_items_with_limit(agent: Agent):
"""Test the limit parameter in get_items."""
session_id = "limit_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
items: list[TResponseInputItem] = [
{"role": "user", "content": "1"},
{"role": "assistant", "content": "2"},
{"role": "user", "content": "3"},
{"role": "assistant", "content": "4"},
]
await session.add_items(items)
# Get last 2 items
latest_2 = await session.get_items(limit=2)
assert len(latest_2) == 2
assert latest_2[0].get("content") == "3"
assert latest_2[1].get("content") == "4"
# Get all items
all_items = await session.get_items()
assert len(all_items) == 4
# Get more than available
more_than_all = await session.get_items(limit=10)
assert len(more_than_all) == 4
async def test_pop_from_empty_session():
"""Test that pop_item returns None on an empty session."""
session = SQLAlchemySession.from_url("empty_session", url=DB_URL, create_tables=True)
popped = await session.pop_item()
assert popped is None
async def test_pop_item_skips_corrupt_most_recent():
"""pop_item skips corrupt newest rows and returns the next valid item."""
session = SQLAlchemySession.from_url("pop_corrupt", url=DB_URL, create_tables=True)
valid_item: TResponseInputItem = {"role": "user", "content": "valid"}
await session.add_items([valid_item])
await session._ensure_tables()
async with session._session_factory() as sess:
async with sess.begin():
await sess.execute(
insert(session._messages).values(
{"session_id": session.session_id, "message_data": "not valid json {{{"}
)
)
assert await session.pop_item() == valid_item
assert await session.get_items() == []
async def test_pop_item_returns_none_after_dropping_only_corrupt_rows():
"""pop_item removes corrupt rows and returns None when no valid items remain."""
session = SQLAlchemySession.from_url("pop_only_corrupt", url=DB_URL, create_tables=True)
await session._ensure_tables()
async with session._session_factory() as sess:
async with sess.begin():
await sess.execute(
insert(session._messages).values(
{"session_id": session.session_id, "message_data": "not valid json {{{"}
)
)
assert await session.pop_item() is None
assert await session.get_items() == []
async def test_add_empty_items_list():
"""Test that adding an empty list of items is a no-op."""
session_id = "add_empty_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
initial_items = await session.get_items()
assert len(initial_items) == 0
await session.add_items([])
items_after_add = await session.get_items()
assert len(items_after_add) == 0
async def test_add_items_concurrent_first_access_with_create_tables(tmp_path):
"""Concurrent first writes should not race table creation or drop items."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'concurrent_first_access.db'}"
session = SQLAlchemySession.from_url(
"concurrent_first_access",
url=db_url,
create_tables=True,
)
submitted = [f"msg-{i}" for i in range(25)]
async def worker(content: str) -> None:
await session.add_items([{"role": "user", "content": content}])
results = await asyncio.gather(
*(worker(content) for content in submitted),
return_exceptions=True,
)
assert [result for result in results if isinstance(result, Exception)] == []
stored = await session.get_items()
assert len(stored) == len(submitted)
stored_contents: list[str] = []
for item in stored:
content = item.get("content")
assert isinstance(content, str)
stored_contents.append(content)
assert sorted(stored_contents) == sorted(submitted)
async def test_add_items_concurrent_first_write_after_tables_exist(tmp_path):
"""Concurrent first writes should not race parent session creation."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'concurrent_first_write.db'}"
setup_session = SQLAlchemySession.from_url(
"concurrent_first_write",
url=db_url,
create_tables=True,
)
await setup_session.get_items()
session = SQLAlchemySession.from_url(
"concurrent_first_write",
url=db_url,
create_tables=False,
)
submitted = [f"msg-{i}" for i in range(25)]
async def worker(content: str) -> None:
await session.add_items([{"role": "user", "content": content}])
results = await asyncio.gather(
*(worker(content) for content in submitted),
return_exceptions=True,
)
assert [result for result in results if isinstance(result, Exception)] == []
stored = await session.get_items()
assert len(stored) == len(submitted)
stored_contents: list[str] = []
for item in stored:
content = item.get("content")
assert isinstance(content, str)
stored_contents.append(content)
assert sorted(stored_contents) == sorted(submitted)
async def test_add_items_waits_for_transient_sqlite_write_lock(tmp_path):
"""SQLite writes should wait briefly for a transient lock instead of failing."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'sqlite_write_lock_retry.db'}"
session = SQLAlchemySession.from_url(
"sqlite_write_lock_retry",
url=db_url,
create_tables=True,
)
await session.get_items()
async with session.engine.connect() as conn:
await conn.execute(text("BEGIN IMMEDIATE"))
blocked_write = asyncio.create_task(
session.add_items([{"role": "user", "content": "after-lock"}])
)
await asyncio.sleep(0.1)
await conn.rollback()
await asyncio.wait_for(blocked_write, timeout=5)
stored = await session.get_items()
assert len(stored) == 1
assert stored[0].get("content") == "after-lock"
async def test_add_items_concurrent_first_access_across_sessions_with_shared_engine(tmp_path):
"""Concurrent first writes should not race table creation across session instances."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'concurrent_shared_engine.db'}"
engine = create_async_engine(db_url)
try:
session_a = SQLAlchemySession("shared_engine_a", engine=engine, create_tables=True)
session_b = SQLAlchemySession("shared_engine_b", engine=engine, create_tables=True)
results = await asyncio.gather(
session_a.add_items([{"role": "user", "content": "one"}]),
session_b.add_items([{"role": "user", "content": "two"}]),
return_exceptions=True,
)
assert [result for result in results if isinstance(result, Exception)] == []
stored_a = await session_a.get_items()
assert len(stored_a) == 1
assert stored_a[0].get("content") == "one"
stored_b = await session_b.get_items()
assert len(stored_b) == 1
assert stored_b[0].get("content") == "two"
finally:
await engine.dispose()
async def test_add_items_concurrent_first_access_across_from_url_sessions(tmp_path):
"""Concurrent first writes should not race table creation across from_url sessions."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'concurrent_from_url.db'}"
session_a = SQLAlchemySession.from_url("from_url_a", url=db_url, create_tables=True)
session_b = SQLAlchemySession.from_url("from_url_b", url=db_url, create_tables=True)
try:
results = await asyncio.gather(
session_a.add_items([{"role": "user", "content": "one"}]),
session_b.add_items([{"role": "user", "content": "two"}]),
return_exceptions=True,
)
assert [result for result in results if isinstance(result, Exception)] == []
stored_a = await session_a.get_items()
assert len(stored_a) == 1
assert stored_a[0].get("content") == "one"
stored_b = await session_b.get_items()
assert len(stored_b) == 1
assert stored_b[0].get("content") == "two"
finally:
await session_a.engine.dispose()
await session_b.engine.dispose()
async def test_add_items_concurrent_first_access_across_from_url_sessions_cross_loop(tmp_path):
"""Concurrent first writes should not race or hang across event loops."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'concurrent_from_url_cross_loop.db'}"
barrier = threading.Barrier(2)
results: list[tuple[str, str, Any]] = []
results_lock = threading.Lock()
def worker(session_id: str, content: str) -> None:
async def run() -> tuple[str, Any]:
session = SQLAlchemySession.from_url(session_id, url=db_url, create_tables=True)
barrier.wait()
try:
await asyncio.wait_for(
session.add_items([{"role": "user", "content": content}]),
timeout=5,
)
stored = await session.get_items()
return ("ok", stored)
finally:
await session.engine.dispose()
try:
status, payload = asyncio.run(run())
except Exception as exc:
status, payload = type(exc).__name__, str(exc)
with results_lock:
results.append((session_id, status, payload))
threads = [
threading.Thread(target=worker, args=("from_url_cross_loop_a", "one")),
threading.Thread(target=worker, args=("from_url_cross_loop_b", "two")),
]
for thread in threads:
thread.start()
for thread in threads:
await asyncio.to_thread(thread.join)
assert len(results) == 2
assert [status for _, status, _ in results] == ["ok", "ok"]
stored_by_session = {
session_id: cast(list[TResponseInputItem], payload) for session_id, _, payload in results
}
assert stored_by_session["from_url_cross_loop_a"][0].get("content") == "one"
assert stored_by_session["from_url_cross_loop_b"][0].get("content") == "two"
async def test_add_items_concurrent_first_access_with_shared_session_cross_loop(tmp_path):
"""A shared session instance should not hang when used from two event loops."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'shared_session_cross_loop.db'}"
session = SQLAlchemySession.from_url(
"shared_session_cross_loop",
url=db_url,
create_tables=True,
)
barrier = threading.Barrier(2)
results: list[tuple[str, str]] = []
results_lock = threading.Lock()
def worker(content: str) -> None:
async def run() -> None:
barrier.wait()
await asyncio.wait_for(
session.add_items([{"role": "user", "content": content}]),
timeout=5,
)
try:
asyncio.run(run())
status = "ok"
except Exception as exc:
status = type(exc).__name__
with results_lock:
results.append((content, status))
threads = [
threading.Thread(target=worker, args=("one",)),
threading.Thread(target=worker, args=("two",)),
]
try:
for thread in threads:
thread.start()
for thread in threads:
await asyncio.to_thread(thread.join)
assert sorted(results) == [("one", "ok"), ("two", "ok")]
stored = await session.get_items()
stored_contents: list[str] = []
for item in stored:
content = item.get("content")
assert isinstance(content, str)
stored_contents.append(content)
assert sorted(stored_contents) == ["one", "two"]
finally:
await session.engine.dispose()
async def test_add_items_cancelled_waiter_does_not_strand_table_init_lock(tmp_path):
"""Cancelling a waiting initializer must not leave the shared init lock acquired."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'cancelled_table_init_waiter.db'}"
holder = SQLAlchemySession.from_url("holder", url=db_url, create_tables=True)
waiter = SQLAlchemySession.from_url("waiter", url=db_url, create_tables=True)
follower = SQLAlchemySession.from_url("follower", url=db_url, create_tables=True)
assert holder._init_lock is waiter._init_lock
assert waiter._init_lock is follower._init_lock
assert holder._init_lock is not None
acquired = holder._init_lock.acquire(blocking=False)
assert acquired
try:
blocked = asyncio.create_task(waiter.add_items([{"role": "user", "content": "waiter"}]))
await asyncio.sleep(0.05)
blocked.cancel()
with pytest.raises(asyncio.CancelledError):
await blocked
finally:
holder._init_lock.release()
try:
await asyncio.wait_for(
follower.add_items([{"role": "user", "content": "follower"}]),
timeout=2,
)
stored = await follower.get_items()
assert len(stored) == 1
assert stored[0].get("content") == "follower"
finally:
await holder.engine.dispose()
await waiter.engine.dispose()
await follower.engine.dispose()
async def test_create_tables_false_does_not_allocate_shared_init_lock(tmp_path):
"""Sessions that skip auto-create should not populate the shared lock map."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'no_create_tables_lock.db'}"
before = len(SQLAlchemySession._table_init_locks)
session = SQLAlchemySession.from_url("no_create_tables_lock", url=db_url, create_tables=False)
try:
assert session._init_lock is None
assert len(SQLAlchemySession._table_init_locks) == before
finally:
await session.engine.dispose()
async def test_get_items_same_timestamp_consistent_order():
"""Test that items with identical timestamps keep insertion order."""
session_id = "same_timestamp_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
older_item = _make_message_item("older_same_ts", "old")
reasoning_item = _make_reasoning_item("rs_same_ts", "...")
message_item = _make_message_item("msg_same_ts", "...")
await session.add_items([older_item])
await session.add_items([reasoning_item, message_item])
async with session._session_factory() as sess:
rows = await sess.execute(
select(session._messages.c.id, session._messages.c.message_data).where(
session._messages.c.session_id == session.session_id
)
)
id_map = {
json.loads(message_json)["id"]: row_id for row_id, message_json in rows.fetchall()
}
shared = datetime(2025, 10, 15, 17, 26, 39, 132483)
older = shared - timedelta(milliseconds=1)
await sess.execute(
update(session._messages)
.where(
session._messages.c.id.in_(
[
id_map["rs_same_ts"],
id_map["msg_same_ts"],
]
)
)
.values(created_at=shared)
)
await sess.execute(
update(session._messages)
.where(session._messages.c.id == id_map["older_same_ts"])
.values(created_at=older)
)
await sess.commit()
real_factory = session._session_factory
class FakeResult:
def __init__(self, rows: Iterable[Any]):
self._rows = list(rows)
def all(self) -> list[Any]:
return list(self._rows)
def needs_shuffle(statement: Any) -> bool:
if not isinstance(statement, Select):
return False
orderings = list(statement._order_by_clause)
if not orderings:
return False
id_asc = session._messages.c.id.asc()
id_desc = session._messages.c.id.desc()
def references_id(clause) -> bool:
try:
return bool(clause.compare(id_asc) or clause.compare(id_desc))
except AttributeError:
return False
if any(references_id(clause) for clause in orderings):
return False
# Only shuffle queries that target the messages table.
target_tables: set[str] = set()
for from_clause in statement.get_final_froms():
name_attr = getattr(from_clause, "name", None)
if isinstance(name_attr, str):
target_tables.add(name_attr)
table_name_obj = getattr(session._messages, "name", "")
table_name = table_name_obj if isinstance(table_name_obj, str) else ""
return bool(table_name in target_tables)
@asynccontextmanager
async def shuffled_session():
async with real_factory() as inner:
original_execute = inner.execute
async def execute_with_shuffle(statement: Any, *args: Any, **kwargs: Any) -> Any:
result = await original_execute(statement, *args, **kwargs)
if needs_shuffle(statement):
rows = result.all()
shuffled = list(rows)
shuffled.reverse()
return FakeResult(shuffled)
return result
cast(Any, inner).execute = execute_with_shuffle
try:
yield inner
finally:
cast(Any, inner).execute = original_execute
session._session_factory = cast(Any, shuffled_session)
try:
retrieved = await session.get_items()
assert _item_ids(retrieved) == ["older_same_ts", "rs_same_ts", "msg_same_ts"]
latest_two = await session.get_items(limit=2)
assert _item_ids(latest_two) == ["rs_same_ts", "msg_same_ts"]
finally:
session._session_factory = real_factory
async def test_pop_item_same_timestamp_returns_latest():
"""Test that pop_item returns the newest item when timestamps tie."""
session_id = "same_timestamp_pop_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
reasoning_item = _make_reasoning_item("rs_pop_same_ts", "...")
message_item = _make_message_item("msg_pop_same_ts", "...")
await session.add_items([reasoning_item, message_item])
async with session._session_factory() as sess:
await sess.execute(
text(
"UPDATE agent_messages SET created_at = :created_at WHERE session_id = :session_id"
),
{
"created_at": "2025-10-15 17:26:39.132483",
"session_id": session.session_id,
},
)
await sess.commit()
popped = await session.pop_item()
assert popped is not None
assert cast(dict[str, Any], popped)["id"] == "msg_pop_same_ts"
remaining = await session.get_items()
assert _item_ids(remaining) == ["rs_pop_same_ts"]
async def test_get_items_orders_by_id_for_ties():
"""Test that get_items adds id ordering to break timestamp ties."""
session_id = "order_by_id_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
await session.add_items(
[
_make_reasoning_item("rs_first", "..."),
_make_message_item("msg_second", "..."),
]
)
real_factory = session._session_factory
recorded: list[Any] = []
@asynccontextmanager
async def wrapped_session():
async with real_factory() as inner:
original_execute = inner.execute
async def recording_execute(statement: Any, *args: Any, **kwargs: Any) -> Any:
recorded.append(statement)
return await original_execute(statement, *args, **kwargs)
cast(Any, inner).execute = recording_execute
try:
yield inner
finally:
cast(Any, inner).execute = original_execute
session._session_factory = cast(Any, wrapped_session)
try:
retrieved_full = await session.get_items()
retrieved_limited = await session.get_items(limit=2)
finally:
session._session_factory = real_factory
assert len(recorded) >= 2
orderings_full = [str(clause) for clause in recorded[0]._order_by_clause]
assert orderings_full == [
"agent_messages.created_at ASC",
"agent_messages.id ASC",
]
orderings_limited = [str(clause) for clause in recorded[1]._order_by_clause]
assert orderings_limited == [
"agent_messages.created_at DESC",
"agent_messages.id DESC",
]
assert _item_ids(retrieved_full) == ["rs_first", "msg_second"]
assert _item_ids(retrieved_limited) == ["rs_first", "msg_second"]
async def test_engine_property_from_url():
"""Test that the engine property returns the AsyncEngine from from_url."""
session_id = "engine_property_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
# Verify engine property returns an AsyncEngine instance
assert isinstance(session.engine, AsyncEngine)
# Verify we can use the engine for advanced operations
# For example, check pool status
assert session.engine.pool is not None
# Verify we can manually dispose the engine
await session.engine.dispose()
async def test_engine_property_from_external_engine():
"""Test that the engine property returns the external engine."""
session_id = "external_engine_test"
# Create engine externally
external_engine = create_async_engine(DB_URL)
# Create session with external engine
session = SQLAlchemySession(session_id, engine=external_engine, create_tables=True)
# Verify engine property returns the same engine instance
assert session.engine is external_engine
# Verify we can use the engine
assert isinstance(session.engine, AsyncEngine)
# Clean up - user is responsible for disposing external engine
await external_engine.dispose()
async def test_engine_property_is_read_only():
"""Test that the engine property cannot be modified."""
session_id = "readonly_engine_test"
session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True)
# Verify engine property exists
assert hasattr(session, "engine")
# Verify it's a property (read-only, cannot be set)
# Type ignore needed because mypy correctly detects this is read-only
with pytest.raises(AttributeError):
session.engine = create_async_engine(DB_URL) # type: ignore[misc]
# Clean up
await session.engine.dispose()
async def test_session_settings_default():
"""Test that session_settings defaults to empty SessionSettings."""
from agents.memory import SessionSettings
session = SQLAlchemySession.from_url("default_settings_test", url=DB_URL, create_tables=True)
# Should have default SessionSettings
assert isinstance(session.session_settings, SessionSettings)
assert session.session_settings.limit is None
async def test_session_settings_from_url():
"""Test passing session_settings via from_url."""
from agents.memory import SessionSettings
session = SQLAlchemySession.from_url(
"from_url_settings_test",
url=DB_URL,
create_tables=True,
session_settings=SessionSettings(limit=5),
)
assert session.session_settings is not None
assert session.session_settings.limit == 5
async def test_get_items_uses_session_settings_limit():
"""Test that get_items uses session_settings.limit as default."""
from agents.memory import SessionSettings
session = SQLAlchemySession.from_url(
"uses_settings_limit_test",
url=DB_URL,
create_tables=True,
session_settings=SessionSettings(limit=3),
)
# Add 5 items
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(5)
]
await session.add_items(items)
# get_items() with no limit should use session_settings.limit=3
retrieved = await session.get_items()
assert len(retrieved) == 3
# Should get the last 3 items
assert retrieved[0].get("content") == "Message 2"
assert retrieved[1].get("content") == "Message 3"
assert retrieved[2].get("content") == "Message 4"
async def test_get_items_explicit_limit_overrides_session_settings():
"""Test that explicit limit parameter overrides session_settings."""
from agents.memory import SessionSettings
session = SQLAlchemySession.from_url(
"explicit_override_test",
url=DB_URL,
create_tables=True,
session_settings=SessionSettings(limit=5),
)
# Add 10 items
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(10)
]
await session.add_items(items)
# Explicit limit=2 should override session_settings.limit=5
retrieved = await session.get_items(limit=2)
assert len(retrieved) == 2
assert retrieved[0].get("content") == "Message 8"
assert retrieved[1].get("content") == "Message 9"
async def test_session_settings_resolve():
"""Test SessionSettings.resolve() method."""
from agents.memory import SessionSettings
base = SessionSettings(limit=100)
override = SessionSettings(limit=50)
final = base.resolve(override)
assert final.limit == 50 # Override wins
assert base.limit == 100 # Original unchanged
# Resolving with None returns self
final_none = base.resolve(None)
assert final_none.limit == 100
async def test_runner_with_session_settings_override(agent: Agent):
"""Test that RunConfig can override session's default settings."""
from agents import RunConfig
from agents.memory import SessionSettings
# Session with default limit=100
session = SQLAlchemySession.from_url(
"runner_override_test",
url=DB_URL,
create_tables=True,
session_settings=SessionSettings(limit=100),
)
# Add some history
items: list[TResponseInputItem] = [{"role": "user", "content": f"Turn {i}"} for i in range(10)]
await session.add_items(items)
# Use RunConfig to override limit to 2
assert isinstance(agent.model, FakeModel)
agent.model.set_next_output([get_text_message("Got it")])
await Runner.run(
agent,
"New question",
session=session,
run_config=RunConfig(
session_settings=SessionSettings(limit=2) # Override to 2
),
)
# Verify the agent received only the last 2 history items + new question
last_input = agent.model.last_turn_args["input"]
# Filter out the new "New question" input
history_items = [item for item in last_input if item.get("content") != "New question"]
# Should have 2 history items (last two from the 10 we added)
assert len(history_items) == 2
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
from typing import Any, cast
import pytest
def _load_example_module() -> Any:
path = (
Path(__file__).resolve().parents[3]
/ "examples"
/ "sandbox"
/ "extensions"
/ "runloop"
/ "capabilities.py"
)
module_name = "tests.extensions.sandbox.runloop_capabilities_example"
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
class _FakeNotFoundError(Exception):
def __init__(self) -> None:
self.status_code = 404
self.response = types.SimpleNamespace(status_code=404)
class _FakeConflictError(Exception):
def __init__(self, message: str) -> None:
self.status_code = 400
self.response = types.SimpleNamespace(status_code=400)
self.body = {"message": message}
class _FakeSecret:
def __init__(self, name: str, secret_id: str) -> None:
self.id = secret_id
self.name = name
class _FakeSecretsClient:
def __init__(self) -> None:
self.secrets: dict[str, _FakeSecret] = {}
self.create_calls: list[tuple[str, str]] = []
self.delete_calls: list[str] = []
self._counter = 0
def add(self, name: str) -> _FakeSecret:
self._counter += 1
secret = _FakeSecret(name=name, secret_id=f"secret-{self._counter}")
self.secrets[name] = secret
return secret
async def get(self, name: str) -> _FakeSecret:
if name not in self.secrets:
raise _FakeNotFoundError()
return self.secrets[name]
async def create(self, *, name: str, value: str) -> _FakeSecret:
self.create_calls.append((name, value))
return self.add(name)
class _FakePolicy:
def __init__(self, policy_id: str, name: str, description: str | None = None) -> None:
self.id = policy_id
self.name = name
self.description = description
class _FakePolicyRef:
def __init__(self, policy: _FakePolicy) -> None:
self._policy = policy
async def get_info(self) -> object:
return types.SimpleNamespace(
id=self._policy.id,
name=self._policy.name,
description=self._policy.description,
)
class _FakeNetworkPoliciesClient:
def __init__(self) -> None:
self.policies: dict[str, _FakePolicy] = {}
self.create_calls: list[dict[str, object]] = []
self.delete_calls: list[str] = []
self._counter = 0
def add(self, name: str, description: str | None = None) -> _FakePolicy:
self._counter += 1
policy = _FakePolicy(
policy_id=f"np-{self._counter}",
name=name,
description=description,
)
self.policies[policy.id] = policy
return policy
async def list(self, **params: object) -> list[_FakePolicy]:
name = params.get("name")
policies = list(self.policies.values())
if isinstance(name, str):
return [policy for policy in policies if policy.name == name]
return policies
async def create(self, **params: object) -> _FakePolicy:
self.create_calls.append(dict(params))
name = str(params["name"])
if any(policy.name == name for policy in self.policies.values()):
raise _FakeConflictError(f"NetworkPolicy with name '{name}' already exists")
description = cast(
str | None,
params.get("description") if isinstance(params.get("description"), str) else None,
)
return self.add(
name=name,
description=description,
)
def get(self, policy_id: str) -> _FakePolicyRef:
return _FakePolicyRef(self.policies[policy_id])
class _FakePlatformClient:
def __init__(self) -> None:
self.secrets = _FakeSecretsClient()
self.network_policies = _FakeNetworkPoliciesClient()
class _FakeRunloopClient:
def __init__(self) -> None:
self.platform = _FakePlatformClient()
@pytest.mark.asyncio
async def test_query_runloop_secret_returns_non_sensitive_metadata() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
secret = client.platform.secrets.add("RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN")
result = await module._query_runloop_secret( # noqa: SLF001
client,
name=secret.name,
)
assert result.found is True
assert result.id == secret.id
assert "value" not in result.model_dump(mode="json")
@pytest.mark.asyncio
async def test_query_runloop_secret_reports_missing_before_create() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
result = await module._query_runloop_secret( # noqa: SLF001
client,
name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
)
assert result.found is False
assert result.id is None
@pytest.mark.asyncio
async def test_query_runloop_network_policy_reports_existing_resource() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
policy = client.platform.network_policies.add(
"runloop-capabilities-example-policy",
description="Persistent example policy.",
)
result = await module._query_runloop_network_policy( # noqa: SLF001
client,
name=policy.name,
)
assert result.found is True
assert result.id == policy.id
assert result.description == "Persistent example policy."
@pytest.mark.asyncio
async def test_bootstrap_persistent_resources_reuses_existing_resources_without_cleanup() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
secret = client.platform.secrets.add("RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN")
policy = client.platform.network_policies.add("runloop-capabilities-example-policy")
query_results = {
"secret": module.RunloopResourceQueryResult(
resource_type="secret",
name=secret.name,
found=True,
id=secret.id,
),
"network_policy": module.RunloopResourceQueryResult(
resource_type="network_policy",
name=policy.name,
found=True,
id=policy.id,
),
}
bootstrap = await module._bootstrap_persistent_resources( # noqa: SLF001
client,
managed_secret_name=secret.name,
managed_secret_value="runloop-capabilities-example-token",
network_policy_name=policy.name,
network_policy_id_override=None,
query_results=query_results,
axon_name=None,
)
secret_bootstrap = bootstrap["secret"]
network_policy_bootstrap = bootstrap["network_policy"]
assert secret_bootstrap.action == "reused"
assert network_policy_bootstrap.action == "reused"
assert client.platform.secrets.create_calls == []
assert client.platform.network_policies.create_calls == []
assert client.platform.secrets.delete_calls == []
assert client.platform.network_policies.delete_calls == []
@pytest.mark.asyncio
async def test_bootstrap_persistent_resources_creates_missing_resources() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
query_results = {
"secret": module.RunloopResourceQueryResult(
resource_type="secret",
name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
found=False,
),
"network_policy": module.RunloopResourceQueryResult(
resource_type="network_policy",
name="runloop-capabilities-example-policy",
found=False,
),
}
bootstrap = await module._bootstrap_persistent_resources( # noqa: SLF001
client,
managed_secret_name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
managed_secret_value="runloop-capabilities-example-token",
network_policy_name="runloop-capabilities-example-policy",
network_policy_id_override=None,
query_results=query_results,
axon_name=None,
)
secret_bootstrap = bootstrap["secret"]
network_policy_bootstrap = bootstrap["network_policy"]
assert secret_bootstrap.action == "created"
assert network_policy_bootstrap.action == "created"
assert client.platform.secrets.create_calls == [
("RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN", "runloop-capabilities-example-token")
]
assert client.platform.network_policies.create_calls == [
{
"name": "runloop-capabilities-example-policy",
"allow_all": True,
"description": "Persistent network policy for the Runloop capabilities example.",
}
]
@pytest.mark.asyncio
async def test_bootstrap_persistent_resources_respects_policy_override() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
query_results = {
"secret": module.RunloopResourceQueryResult(
resource_type="secret",
name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
found=False,
),
"network_policy": module.RunloopResourceQueryResult(
resource_type="network_policy",
name="runloop-capabilities-example-policy",
found=False,
),
}
bootstrap = await module._bootstrap_persistent_resources( # noqa: SLF001
client,
managed_secret_name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
managed_secret_value="runloop-capabilities-example-token",
network_policy_name="runloop-capabilities-example-policy",
network_policy_id_override="np-override",
query_results=query_results,
axon_name=None,
)
network_policy_bootstrap = bootstrap["network_policy"]
assert network_policy_bootstrap.action == "override"
assert network_policy_bootstrap.id == "np-override"
assert client.platform.network_policies.create_calls == []
@pytest.mark.asyncio
async def test_bootstrap_persistent_resources_recovers_from_existing_policy_conflict() -> None:
module = _load_example_module()
client = _FakeRunloopClient()
policy = client.platform.network_policies.add(
"runloop-capabilities-example-policy",
description="Persistent example policy.",
)
query_results = {
"secret": module.RunloopResourceQueryResult(
resource_type="secret",
name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
found=False,
),
"network_policy": module.RunloopResourceQueryResult(
resource_type="network_policy",
name=policy.name,
found=False,
),
}
bootstrap = await module._bootstrap_persistent_resources( # noqa: SLF001
client,
managed_secret_name="RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN",
managed_secret_value="runloop-capabilities-example-token",
network_policy_name=policy.name,
network_policy_id_override=None,
query_results=query_results,
axon_name=None,
)
network_policy_bootstrap = bootstrap["network_policy"]
assert network_policy_bootstrap.action == "reused"
assert network_policy_bootstrap.found_before_bootstrap is True
assert network_policy_bootstrap.id == policy.id
@@ -0,0 +1,224 @@
from __future__ import annotations
import io
import types
import uuid
from pathlib import Path
from typing import Any, cast
import pytest
from agents.sandbox import Manifest
from agents.sandbox.entries import RcloneMountPattern, S3Mount
from agents.sandbox.errors import MountConfigError
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.types import ExecResult
class _FakeRunloopMountSession(BaseSandboxSession):
def __init__(self, results: list[ExecResult] | None = None) -> None:
self.state = cast(
Any,
types.SimpleNamespace(
session_id=uuid.uuid4(),
manifest=Manifest(root="/workspace"),
),
)
self._results = list(results or [])
self.exec_calls: list[str] = []
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
_ = timeout
cmd_str = " ".join(str(c) for c in command)
self.exec_calls.append(cmd_str)
if self._results:
return self._results.pop(0)
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
_ = (path, user)
return io.BytesIO(b"")
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
_ = (path, data, user)
async def persist_workspace(self) -> io.IOBase:
raise AssertionError("not expected")
async def hydrate_workspace(self, data: io.IOBase) -> None:
_ = data
raise AssertionError("not expected")
async def running(self) -> bool:
return True
_FakeRunloopMountSession.__name__ = "RunloopSandboxSession"
def _exec_ok(stdout: bytes = b"") -> ExecResult:
return ExecResult(stdout=stdout, stderr=b"", exit_code=0)
def _exec_fail() -> ExecResult:
return ExecResult(stdout=b"", stderr=b"", exit_code=1)
def test_runloop_package_re_exports_cloud_bucket_strategy() -> None:
package_module = __import__(
"agents.extensions.sandbox.runloop",
fromlist=["RunloopCloudBucketMountStrategy"],
)
assert hasattr(package_module, "RunloopCloudBucketMountStrategy")
def test_runloop_extension_re_exports_cloud_bucket_strategy() -> None:
package_module = __import__(
"agents.extensions.sandbox",
fromlist=["RunloopCloudBucketMountStrategy"],
)
assert hasattr(package_module, "RunloopCloudBucketMountStrategy")
def test_runloop_mount_strategy_type_and_default_pattern() -> None:
from agents.extensions.sandbox.runloop.mounts import RunloopCloudBucketMountStrategy
strategy = RunloopCloudBucketMountStrategy()
assert strategy.type == "runloop_cloud_bucket"
assert isinstance(strategy.pattern, RcloneMountPattern)
assert strategy.pattern.mode == "fuse"
def test_runloop_mount_strategy_round_trips_through_manifest() -> None:
from agents.extensions.sandbox.runloop.mounts import RunloopCloudBucketMountStrategy
manifest = Manifest.model_validate(
{
"root": "/workspace",
"entries": {
"bucket": {
"type": "s3_mount",
"bucket": "my-bucket",
"mount_strategy": {"type": "runloop_cloud_bucket"},
}
},
}
)
mount = manifest.entries["bucket"]
assert isinstance(mount, S3Mount)
assert isinstance(mount.mount_strategy, RunloopCloudBucketMountStrategy)
def test_runloop_session_guard_rejects_wrong_type() -> None:
from agents.extensions.sandbox.runloop.mounts import _assert_runloop_session
class _WrongSession:
pass
with pytest.raises(MountConfigError, match="RunloopSandboxSession"):
_assert_runloop_session(_WrongSession()) # type: ignore[arg-type]
def test_runloop_session_guard_accepts_correct_type() -> None:
from agents.extensions.sandbox.runloop.mounts import _assert_runloop_session
_assert_runloop_session(_FakeRunloopMountSession())
@pytest.mark.asyncio
async def test_runloop_ensure_rclone_installs_with_root_apt() -> None:
from agents.extensions.sandbox._rclone import ensure_rclone
session = _FakeRunloopMountSession(
[
_exec_fail(),
_exec_ok(),
_exec_ok(),
_exec_ok(),
_exec_ok(),
]
)
await ensure_rclone(session)
assert session.exec_calls[:2] == [
"sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone",
"sh -lc command -v apt-get >/dev/null 2>&1",
]
assert session.exec_calls[2] == (
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 update -qq"
)
assert session.exec_calls[3] == (
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 install -y -qq "
"curl unzip ca-certificates"
)
assert (
session.exec_calls[4]
== "sudo -u root -- sh -lc curl -fsSL https://rclone.org/install.sh | bash"
)
assert session.exec_calls[5] == (
"sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone"
)
@pytest.mark.asyncio
async def test_runloop_ensure_fuse_installs_missing_fusermount() -> None:
from agents.extensions.sandbox.runloop.mounts import _ensure_fuse_support
session = _FakeRunloopMountSession(
[
_exec_ok(),
_exec_ok(),
_exec_fail(),
_exec_ok(),
_exec_ok(),
_exec_ok(),
_exec_ok(),
_exec_ok(),
]
)
await _ensure_fuse_support(session)
assert session.exec_calls == [
"sh -lc test -c /dev/fuse",
"sh -lc grep -qw fuse /proc/filesystems",
"sh -lc command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1",
"sh -lc command -v apt-get >/dev/null 2>&1",
(
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 update -qq"
),
(
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 install -y -qq fuse3"
),
"sh -lc command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1",
(
"sudo -u root -- sh -lc chmod a+rw /dev/fuse && "
"touch /etc/fuse.conf && "
"(grep -qxF user_allow_other /etc/fuse.conf || "
"printf '\\nuser_allow_other\\n' >> /etc/fuse.conf)"
),
]
@pytest.mark.asyncio
async def test_runloop_rclone_pattern_adds_fuse_access_args() -> None:
from agents.extensions.sandbox._rclone import rclone_pattern_for_session
session = _FakeRunloopMountSession([_exec_ok(stdout=b"1000\n1000\n")])
pattern = await rclone_pattern_for_session(session, RcloneMountPattern(mode="fuse"))
assert pattern.extra_args == ["--allow-other", "--uid", "1000", "--gid", "1000"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
"""Tests for ToolOutputTrimmer — the built-in call_model_input_filter for trimming
large tool outputs from older conversation turns.
"""
from __future__ import annotations
import copy
import json
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from agents.extensions.tool_output_trimmer import ToolOutputTrimmer
from agents.run_config import CallModelData, ModelInputData
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _user(text: str = "hello") -> dict[str, Any]:
return {"role": "user", "content": text}
def _assistant(text: str = "response") -> dict[str, Any]:
return {"role": "assistant", "content": text}
def _func_call(call_id: str, name: str, *, namespace: str | None = None) -> dict[str, Any]:
item = {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"}
if namespace is not None:
item["namespace"] = namespace
return item
def _func_output(call_id: str, output: str) -> dict[str, Any]:
return {"type": "function_call_output", "call_id": call_id, "output": output}
def _make_data(items: list[Any]) -> CallModelData[Any]:
model_data = ModelInputData(input=items, instructions="You are helpful.")
return CallModelData(model_data=model_data, agent=MagicMock(), context=None)
def _output(result: ModelInputData, idx: int) -> Any:
"""Extract the ``output`` field from a result item (untyped for test convenience)."""
item: Any = result.input[idx]
return item["output"]
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
class TestDefaults:
def test_default_values(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer.recent_turns == 2
assert trimmer.max_output_chars == 500
assert trimmer.preview_chars == 200
assert trimmer.trimmable_tools is None
def test_trimmable_tools_coerced_to_frozenset(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"a", "b"}))
assert isinstance(trimmer.trimmable_tools, frozenset)
assert trimmer.trimmable_tools == frozenset({"a", "b"})
def test_trimmable_tools_from_list(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools=["search", "run_code"])
assert isinstance(trimmer.trimmable_tools, frozenset)
assert "search" in trimmer.trimmable_tools
assert "run_code" in trimmer.trimmable_tools
def test_trimmable_tools_from_string(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools="search")
assert isinstance(trimmer.trimmable_tools, frozenset)
assert trimmer.trimmable_tools == frozenset({"search"})
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_recent_turns_zero_raises(self) -> None:
with pytest.raises(ValueError, match="recent_turns must be >= 1"):
ToolOutputTrimmer(recent_turns=0)
def test_recent_turns_negative_raises(self) -> None:
with pytest.raises(ValueError, match="recent_turns must be >= 1"):
ToolOutputTrimmer(recent_turns=-1)
def test_max_output_chars_zero_raises(self) -> None:
with pytest.raises(ValueError, match="max_output_chars must be >= 1"):
ToolOutputTrimmer(max_output_chars=0)
def test_preview_chars_negative_raises(self) -> None:
with pytest.raises(ValueError, match="preview_chars must be >= 0"):
ToolOutputTrimmer(preview_chars=-1)
def test_preview_chars_zero_allowed(self) -> None:
trimmer = ToolOutputTrimmer(preview_chars=0)
assert trimmer.preview_chars == 0
def test_trimmable_tools_bytes_raises(self) -> None:
with pytest.raises(ValueError, match="trimmable_tools must be a string or iterable"):
ToolOutputTrimmer(trimmable_tools=b"search") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
class TestRecentBoundary:
def test_empty_items(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer._find_recent_boundary([]) == 0
def test_single_user_message(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer._find_recent_boundary([_user()]) == 0
def test_two_user_messages_boundary_at_first(self) -> None:
items = [_user("q1"), _assistant("a1"), _user("q2"), _assistant("a2")]
trimmer = ToolOutputTrimmer(recent_turns=2)
assert trimmer._find_recent_boundary(items) == 0
def test_three_user_messages(self) -> None:
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(recent_turns=2)
assert trimmer._find_recent_boundary(items) == 2
def test_custom_recent_turns(self) -> None:
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
_user("q4"),
_assistant("a4"),
]
trimmer = ToolOutputTrimmer(recent_turns=3)
# q4 at 6 (count=1), q3 at 4 (count=2), q2 at 2 (count=3) -> boundary=2
assert trimmer._find_recent_boundary(items) == 2
# ---------------------------------------------------------------------------
# Trimming behavior
# ---------------------------------------------------------------------------
class TestTrimming:
def test_empty_input(self) -> None:
trimmer = ToolOutputTrimmer()
data = _make_data([])
result = trimmer(data)
assert result.input == []
def test_no_trimming_when_all_recent(self) -> None:
"""With only 1 user message, everything is recent."""
large = "x" * 1000
items = [
_user("q"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert _output(result, 2) == large
def test_trims_large_old_output(self) -> None:
"""Large output in an old turn should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
trimmed = _output(result, 2)
assert "[Trimmed:" in trimmed
assert "search" in trimmed
assert "1000 chars" in trimmed
assert len(trimmed) < len(large)
def test_preserves_small_old_output(self) -> None:
"""Small outputs should never be trimmed."""
small = "x" * 100
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", small),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=500)
result = trimmer(_make_data(items))
assert _output(result, 2) == small
def test_respects_trimmable_tools_allowlist(self) -> None:
"""Only outputs from tools in trimmable_tools should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_func_call("c2", "resolve_entity"),
_func_output("c2", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"search"}))
result = trimmer(_make_data(items))
# search output trimmed
assert "[Trimmed:" in _output(result, 2)
# resolve_entity output preserved
assert _output(result, 4) == large
def test_string_trimmable_tools_allowlist_matches_single_tool_name(self) -> None:
"""A string trimmable_tools value should match one tool name, not characters."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_func_call("c2", "s"),
_func_output("c2", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools="search")
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert _output(result, 4) == large
def test_respects_qualified_tool_names_allowlist(self) -> None:
"""Qualified allowlist entries should match namespaced function tools."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "lookup_account", namespace="billing"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"billing.lookup_account"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "billing.lookup_account" in _output(result, 2)
def test_namespaced_tools_still_match_bare_allowlist_entries(self) -> None:
"""Bare allowlist entries remain valid for namespaced tools."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "lookup_account", namespace="billing"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"lookup_account"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "billing.lookup_account" in _output(result, 2)
def test_synthetic_same_name_namespace_uses_bare_display_name(self) -> None:
"""Deferred synthetic namespaces should not display as `name.name`."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "get_weather", namespace="get_weather"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"get_weather"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "get_weather.get_weather" not in _output(result, 2)
assert "get_weather" in _output(result, 2)
def test_trims_tool_search_output_tool_definitions(self) -> None:
"""Large tool_search_output tool definitions should be structurally trimmed."""
verbose_schema = {
"type": "object",
"description": "schema " * 200,
"properties": {
"customer_id": {
"type": "string",
"description": "customer id " * 200,
"default": "cust_123",
}
},
"required": ["customer_id"],
}
items = [
_user("q1"),
{"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}},
{
"type": "tool_search_output",
"call_id": "ts1",
"tools": [
{
"type": "function",
"name": "lookup_account",
"description": "tool description " * 200,
"parameters": verbose_schema,
}
],
},
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
original_len = len(json.dumps(items[2]["tools"], sort_keys=True))
trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60)
result = trimmer(_make_data(items))
trimmed_item_dict = cast(dict[str, Any], result.input[2])
assert trimmed_item_dict["type"] == "tool_search_output"
trimmed_tools = list(trimmed_item_dict["tools"])
assert trimmed_tools[0]["name"] == "lookup_account"
assert "description" not in trimmed_tools[0]["parameters"]
assert trimmed_tools[0]["parameters"]["properties"]["customer_id"]["default"] == "cust_123"
assert len(json.dumps(trimmed_tools, sort_keys=True)) < original_len
def test_trims_legacy_tool_search_output_results(self) -> None:
"""Legacy tool_search_output snapshots with free-text results should still trim."""
large = "x" * 2000
items = [
_user("q1"),
{"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}},
{
"type": "tool_search_output",
"call_id": "ts1",
"results": [{"text": large}],
},
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=80)
result = trimmer(_make_data(items))
trimmed_item = cast(dict[str, Any], result.input[2])
assert trimmed_item["type"] == "tool_search_output"
assert "[Trimmed: tool_search output" in trimmed_item["results"][0]["text"]
def test_trims_all_tools_when_allowlist_is_none(self) -> None:
"""When trimmable_tools is None, all tools are eligible."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "any_tool"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=None)
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
def test_preserves_recent_large_output(self) -> None:
"""Large outputs in recent turns should never be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert _output(result, 4) == large
def test_does_not_mutate_original_items(self) -> None:
"""The filter must not mutate the original input items."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
original = copy.deepcopy(items)
trimmer = ToolOutputTrimmer()
trimmer(_make_data(items))
assert items == original
def test_preserves_instructions(self) -> None:
"""The instructions field should pass through unchanged."""
items: list[Any] = [_user("hi")]
model_data = ModelInputData(input=items, instructions="Custom prompt")
data: CallModelData[Any] = CallModelData(
model_data=model_data, agent=MagicMock(), context=None
)
trimmer = ToolOutputTrimmer()
result = trimmer(data)
assert result.instructions == "Custom prompt"
def test_multiple_old_outputs_trimmed(self) -> None:
"""Multiple large outputs in old turns should all be trimmed."""
large1 = "a" * 1000
large2 = "b" * 2000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large1),
_func_call("c2", "execute"),
_func_output("c2", large2),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "[Trimmed:" in _output(result, 4)
assert "search" in _output(result, 2)
assert "execute" in _output(result, 4)
def test_custom_preview_chars(self) -> None:
"""Preview length should respect the preview_chars setting."""
large = "abcdefghij" * 100 # 1000 chars
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(preview_chars=50)
result = trimmer(_make_data(items))
trimmed = _output(result, 2)
# The preview portion should be exactly 50 chars of the original
assert "abcdefghij" * 5 in trimmed
def test_preserves_user_and_assistant_messages(self) -> None:
"""User and assistant messages are never modified."""
items = [
_user("important"),
_assistant("detailed " * 100),
_user("follow up"),
_assistant("another"),
_user("final"),
_assistant("done"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert result.input == items
# ---------------------------------------------------------------------------
# Sliding window behavior
# ---------------------------------------------------------------------------
class TestSlidingWindow:
"""Verify the trimmer acts as a sliding window across turns."""
def test_turn3_trims_turn1(self) -> None:
"""On turn 3, turn 1 outputs should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_func_call("c2", "search"),
_func_output("c2", large),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
# Turn 1 (old) trimmed
assert "[Trimmed:" in _output(result, 2)
# Turn 2 (recent) preserved
assert _output(result, 6) == large
def test_turn4_trims_turns_1_and_2(self) -> None:
"""On turn 4, turns 1 and 2 outputs should both be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "s"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_func_call("c2", "s"),
_func_output("c2", large),
_assistant("a2"),
_user("q3"),
_func_call("c3", "s"),
_func_output("c3", large),
_assistant("a3"),
_user("q4"),
_assistant("a4"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
# Turns 1 and 2 trimmed
assert "[Trimmed:" in _output(result, 2)
assert "[Trimmed:" in _output(result, 6)
# Turn 3 (recent) preserved
assert _output(result, 10) == large
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_skips_trim_when_summary_would_exceed_original(self) -> None:
"""When preview_chars is large relative to the output, the summary can be
longer than the original. In that case the output should be left untouched."""
# Output is 501 chars (just above default max_output_chars=500).
# With preview_chars=490, the summary header + 490-char preview + "..." will
# easily exceed 501 chars, so trimming should be skipped.
borderline = "x" * 501
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", borderline),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=500, preview_chars=490)
result = trimmer(_make_data(items))
# Output left untouched because summary would be longer
assert _output(result, 2) == borderline
def test_unknown_tool_name_fallback(self) -> None:
"""When a function_call_output has no matching function_call, the summary
should show 'unknown_tool' instead of a blank name."""
large = "x" * 1000
# Deliberately omit the _func_call so the call_id has no name mapping
items = [
_user("q1"),
_func_output("orphan_id", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
trimmed = _output(result, 1)
assert "unknown_tool" in trimmed
assert "[Trimmed:" in trimmed
def test_unresolved_tool_skipped_with_allowlist(self) -> None:
"""When trimmable_tools is set and the tool name can't be resolved,
the output should NOT be trimmed (empty string won't match the allowlist)."""
large = "x" * 1000
items = [
_user("q1"),
_func_output("orphan_id", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"search"}))
result = trimmer(_make_data(items))
# Unresolved tool name is "" which is not in the allowlist — left untouched
assert _output(result, 1) == large