758 lines
25 KiB
Python
758 lines
25 KiB
Python
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
|