Files
openai--openai-agents-python/tests/extensions/experiemental/codex/test_codex_exec_thread.py
T
2026-07-13 12:39:17 +08:00

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