chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user