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

2049 lines
66 KiB
Python

from __future__ import annotations
import asyncio
import copy
import dataclasses
import importlib
import inspect
import json
from dataclasses import dataclass, fields
from types import MappingProxyType, SimpleNamespace
from typing import Any, cast
import pytest
from openai.types.responses import ResponseFunctionToolCall
from pydantic import BaseModel, ConfigDict
from agents import Agent, function_tool
from agents.exceptions import ModelBehaviorError, UserError
from agents.extensions.experimental.codex import (
Codex,
CodexToolOptions,
CodexToolResult,
CodexToolStreamEvent,
Usage,
codex_tool,
)
from agents.extensions.experimental.codex.codex_tool import CodexToolInputItem
from agents.lifecycle import RunHooks
from agents.run_config import RunConfig
from agents.run_context import RunContextWrapper
from agents.run_internal.agent_bindings import bind_public_agent
from agents.run_internal.run_steps import ToolRunFunction
from agents.run_internal.tool_execution import execute_function_tool_calls
from agents.tool_context import ToolContext
from agents.tracing import function_span, trace
from tests.test_responses import get_function_tool_call
from tests.testing_processor import SPAN_PROCESSOR_TESTING
codex_tool_module = importlib.import_module("agents.extensions.experimental.codex.codex_tool")
class CodexMockState:
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
self.thread_id: str | None = "thread-1"
self.last_turn_options: Any = None
self.start_calls = 0
self.resume_calls = 0
self.last_resumed_thread_id: str | None = None
self.options: Any = None
class FakeThread:
def __init__(self, state: CodexMockState) -> None:
self._state = state
self.id: str | None = None
async def run_streamed(self, _input: Any, turn_options: Any = None) -> Any:
self._state.last_turn_options = turn_options
self.id = self._state.thread_id
async def event_stream() -> Any:
for event in self._state.events:
if event.get("type") == "raise_cancelled":
raise asyncio.CancelledError(event.get("message", "codex-cancelled"))
if event.get("type") == "wait_for_cancel":
started_event = cast(asyncio.Event | None, event.get("started_event"))
if started_event is not None:
started_event.set()
await asyncio.Future()
yield event
return SimpleNamespace(events=event_stream())
class FakeCodex:
def __init__(self, state: CodexMockState, options: Any = None) -> None:
self._state = state
self._state.options = options
def start_thread(self, _options: Any = None) -> FakeThread:
self._state.start_calls += 1
return FakeThread(self._state)
def resume_thread(self, _thread_id: str, _options: Any = None) -> FakeThread:
self._state.resume_calls += 1
self._state.last_resumed_thread_id = _thread_id
return FakeThread(self._state)
def test_codex_tool_kw_matches_codex_tool_options() -> None:
signature = inspect.signature(codex_tool)
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(CodexToolOptions)]
assert kw_only == option_fields
@pytest.mark.asyncio
async def test_codex_tool_streams_events_and_updates_usage() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{"type": "turn.started"},
{
"type": "item.started",
"item": {"id": "reason-1", "type": "reasoning", "text": "Initial reasoning"},
},
{
"type": "item.updated",
"item": {"id": "reason-1", "type": "reasoning", "text": "Refined reasoning"},
},
{
"type": "item.completed",
"item": {"id": "reason-1", "type": "reasoning", "text": "Final reasoning"},
},
{
"type": "item.started",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "pytest",
"aggregated_output": "",
"status": "in_progress",
},
},
{
"type": "item.updated",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "pytest",
"aggregated_output": "Running tests",
"status": "in_progress",
},
},
{
"type": "item.completed",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "pytest",
"aggregated_output": "All good",
"exit_code": 0,
"status": "completed",
},
},
{
"type": "item.started",
"item": {
"id": "mcp-1",
"type": "mcp_tool_call",
"server": "gitmcp",
"tool": "search_codex_code",
"arguments": {"query": "foo"},
"status": "in_progress",
},
},
{
"type": "item.updated",
"item": {
"id": "mcp-1",
"type": "mcp_tool_call",
"server": "gitmcp",
"tool": "search_codex_code",
"arguments": {"query": "foo"},
"status": "in_progress",
},
},
{
"type": "item.completed",
"item": {
"id": "mcp-1",
"type": "mcp_tool_call",
"server": "gitmcp",
"tool": "search_codex_code",
"arguments": {"query": "foo"},
"status": "completed",
"result": {"content": [], "structured_content": None},
},
},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex finished."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 10, "cached_input_tokens": 1, "output_tokens": 5},
},
]
tool = codex_tool(CodexToolOptions(codex=cast(Codex, FakeCodex(state))))
input_json = '{"inputs": [{"type": "text", "text": "Diagnose failure", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with trace("codex-test"):
with function_span(tool.name):
result = await tool.on_invoke_tool(context, input_json)
assert isinstance(result, CodexToolResult)
assert result.thread_id == "thread-1"
assert result.response == "Codex finished."
assert result.usage == Usage(
input_tokens=10,
cached_input_tokens=1,
output_tokens=5,
)
assert context.usage.total_tokens == 15
assert context.usage.requests == 1
spans = SPAN_PROCESSOR_TESTING.get_ordered_spans()
function_span_obj = next(
span
for span in spans
if span.span_data.type == "function" and span.span_data.name == tool.name
)
custom_spans = [span for span in spans if span.span_data.type == "custom"]
assert len(custom_spans) == 1
for span in custom_spans:
assert span.parent_id == function_span_obj.span_id
command_span = next(
span for span in custom_spans if span.span_data.name == "Codex command execution"
)
assert command_span.span_data.data["command"] == "pytest"
assert command_span.span_data.data["status"] == "completed"
assert command_span.span_data.data["output"] == "All good"
assert command_span.span_data.data["exit_code"] == 0
@pytest.mark.asyncio
async def test_codex_tool_keeps_command_output_when_completed_missing_output() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.started",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "ls",
"aggregated_output": "",
"status": "in_progress",
},
},
{
"type": "item.updated",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "ls",
"aggregated_output": "first output",
"status": "in_progress",
},
},
{
"type": "item.completed",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "ls",
"exit_code": 0,
"status": "completed",
},
},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex finished."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(CodexToolOptions(codex=cast(Codex, FakeCodex(state))))
input_json = '{"inputs": [{"type": "text", "text": "List files", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with trace("codex-test"):
with function_span(tool.name):
await tool.on_invoke_tool(context, input_json)
spans = SPAN_PROCESSOR_TESTING.get_ordered_spans()
command_span = next(span for span in spans if span.span_data.name == "Codex command execution")
assert command_span.span_data.data["output"] == "first output"
@pytest.mark.asyncio
async def test_codex_tool_defaults_to_openai_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.delenv("CODEX_API_KEY", raising=False)
class CaptureCodex(FakeCodex):
def __init__(self, options: Any = None) -> None:
super().__init__(state, options)
monkeypatch.setattr(codex_tool_module, "Codex", CaptureCodex)
tool = codex_tool()
input_json = '{"inputs": [{"type": "text", "text": "Check default api key", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.options is not None
assert getattr(state.options, "api_key", None) == "openai-key"
@pytest.mark.asyncio
async def test_codex_tool_accepts_codex_options_dict(monkeypatch: pytest.MonkeyPatch) -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
class CaptureCodex(FakeCodex):
def __init__(self, options: Any = None) -> None:
super().__init__(state, options)
monkeypatch.setattr(codex_tool_module, "Codex", CaptureCodex)
tool = codex_tool({"codex_options": {"api_key": "from-options"}})
input_json = '{"inputs": [{"type": "text", "text": "Check dict options", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.options is not None
assert getattr(state.options, "api_key", None) == "from-options"
@pytest.mark.asyncio
async def test_codex_tool_accepts_output_schema_descriptor() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
descriptor = {
"title": "Summary",
"properties": [
{
"name": "summary",
"description": "Short summary",
"schema": {"type": "string", "description": "Summary field"},
}
],
}
tool = codex_tool(
CodexToolOptions(codex=cast(Codex, FakeCodex(state)), output_schema=descriptor)
)
input_json = '{"inputs": [{"type": "text", "text": "Check schema", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
output_schema = state.last_turn_options.output_schema
assert output_schema["type"] == "object"
assert output_schema["additionalProperties"] is False
assert output_schema["properties"]["summary"]["type"] == "string"
assert output_schema["properties"]["summary"]["description"] == "Short summary"
assert output_schema["required"] == []
@pytest.mark.asyncio
async def test_codex_tool_accepts_dict_options() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
options_dict: dict[str, Any] = {
"codex": cast(Codex, FakeCodex(state)),
"sandbox_mode": "read-only",
}
tool = codex_tool(options_dict)
input_json = '{"inputs": [{"type": "text", "text": "Check dict options", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
result = await tool.on_invoke_tool(context, input_json)
assert isinstance(result, CodexToolResult)
assert result.response == "Codex done."
@pytest.mark.asyncio
async def test_codex_tool_accepts_keyword_options(monkeypatch: pytest.MonkeyPatch) -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
class CaptureCodex(FakeCodex):
def __init__(self, options: Any = None) -> None:
super().__init__(state, options)
monkeypatch.setattr(codex_tool_module, "Codex", CaptureCodex)
tool = codex_tool(name="codex_keyword", codex_options={"api_key": "from-kwargs"})
input_json = '{"inputs": [{"type": "text", "text": "Check keyword options", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert tool.name == "codex_keyword"
assert state.options is not None
assert getattr(state.options, "api_key", None) == "from-kwargs"
def test_codex_tool_truncates_span_values() -> None:
value = {"payload": "x" * 200}
truncated = codex_tool_module._truncate_span_value(value, 40)
assert isinstance(truncated, dict)
assert truncated["truncated"] is True
assert truncated["original_length"] > 40
preview = truncated["preview"]
assert isinstance(preview, str)
assert len(preview) <= 40
def test_codex_tool_enforces_span_data_budget() -> None:
data = {
"command": "run",
"output": "x" * 5000,
"arguments": {"payload": "y" * 5000},
}
trimmed = codex_tool_module._enforce_span_data_budget(data, 512)
assert "command" in trimmed
assert trimmed["command"]
assert "output" in trimmed
assert "arguments" in trimmed
assert codex_tool_module._json_char_size(trimmed) <= 512
def test_codex_tool_keeps_output_preview_with_budget() -> None:
data = {"output": "x" * 1000}
trimmed = codex_tool_module._enforce_span_data_budget(data, 120)
assert "output" in trimmed
assert isinstance(trimmed["output"], str)
assert trimmed["output"]
assert codex_tool_module._json_char_size(trimmed) <= 120
def test_codex_tool_prioritizes_arguments_over_large_results() -> None:
data = {"arguments": {"foo": "bar"}, "result": "x" * 2000}
trimmed = codex_tool_module._enforce_span_data_budget(data, 200)
assert trimmed["arguments"] == codex_tool_module._stringify_span_value({"foo": "bar"})
assert "result" in trimmed
assert codex_tool_module._json_char_size(trimmed) <= 200
@pytest.mark.asyncio
async def test_codex_tool_passes_idle_timeout_seconds() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
default_turn_options={"idle_timeout_seconds": 3.5},
)
)
input_json = '{"inputs": [{"type": "text", "text": "Check timeout option", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.last_turn_options is not None
assert state.last_turn_options.idle_timeout_seconds == 3.5
@pytest.mark.asyncio
async def test_codex_tool_persists_session() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
persist_session=True,
)
)
input_json = '{"inputs": [{"type": "text", "text": "First call", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 1
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_accepts_thread_id_from_tool_input() -> None:
state = CodexMockState()
state.thread_id = "thread-from-input"
state.events = [
{"type": "thread.started", "thread_id": "thread-from-input"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(CodexToolOptions(codex=cast(Codex, FakeCodex(state))))
input_json = (
'{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}], '
'"thread_id": "thread-xyz"}'
)
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
result = await tool.on_invoke_tool(context, input_json)
assert isinstance(result, CodexToolResult)
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-xyz"
assert result.thread_id == "thread-from-input"
@pytest.mark.asyncio
async def test_codex_tool_uses_run_context_thread_id_and_persists_latest() -> None:
state = CodexMockState()
state.thread_id = "thread-next"
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context = {"codex_agent_thread_id": "thread-prev"}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
result = await tool.on_invoke_tool(context, input_json)
assert isinstance(result, CodexToolResult)
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-prev"
assert run_context["codex_agent_thread_id"] == "thread-next"
assert result.thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_persists_thread_started_id_when_thread_object_id_is_none() -> None:
state = CodexMockState()
state.thread_id = None
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context: dict[str, str] = {}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
first_result = await tool.on_invoke_tool(context, input_json)
second_result = await tool.on_invoke_tool(context, input_json)
assert isinstance(first_result, CodexToolResult)
assert isinstance(second_result, CodexToolResult)
assert first_result.thread_id == "thread-next"
assert second_result.thread_id == "thread-next"
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_persists_thread_id_for_recoverable_turn_failure() -> None:
state = CodexMockState()
state.thread_id = None
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{"type": "turn.failed", "error": {"message": "boom"}},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
failure_error_function=lambda _ctx, _exc: "handled",
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context: dict[str, str] = {}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
first_result = await tool.on_invoke_tool(context, input_json)
second_result = await tool.on_invoke_tool(context, input_json)
assert first_result == "handled"
assert second_result == "handled"
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_persists_thread_id_for_raised_turn_failure() -> None:
state = CodexMockState()
state.thread_id = None
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{"type": "turn.failed", "error": {"message": "boom"}},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context: dict[str, str] = {}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="Codex turn failed: boom"):
await tool.on_invoke_tool(context, input_json)
assert run_context["codex_agent_thread_id"] == "thread-next"
with pytest.raises(UserError, match="Codex turn failed: boom"):
await tool.on_invoke_tool(context, input_json)
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_persists_thread_id_for_cancelled_turn() -> None:
state = CodexMockState()
state.thread_id = None
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{"type": "raise_cancelled", "message": "codex-cancelled"},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context: dict[str, str] = {}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(asyncio.CancelledError, match="codex-cancelled"):
await tool.on_invoke_tool(context, input_json)
assert run_context["codex_agent_thread_id"] == "thread-next"
state.events = [
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
result = await tool.on_invoke_tool(context, input_json)
assert isinstance(result, CodexToolResult)
assert result.thread_id == "thread-next"
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_persists_thread_id_for_handled_parallel_cancellation() -> None:
state = CodexMockState()
state.thread_id = None
codex_thread_started = asyncio.Event()
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{"type": "wait_for_cancel", "started_event": codex_thread_started},
]
codex_function_tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
run_context_thread_id_key="codex_agent_thread_id",
)
)
async def _error_tool() -> str:
await codex_thread_started.wait()
raise ValueError("boom")
error_tool = function_tool(
_error_tool,
name_override="error_tool",
failure_error_function=None,
)
agent = Agent(name="test", tools=[codex_function_tool, error_tool])
run_context: dict[str, str] = {}
context_wrapper = RunContextWrapper(run_context)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
tool_runs = [
ToolRunFunction(
tool_call=cast(
ResponseFunctionToolCall,
get_function_tool_call(codex_function_tool.name, input_json, call_id="1"),
),
function_tool=codex_function_tool,
),
ToolRunFunction(
tool_call=cast(
ResponseFunctionToolCall,
get_function_tool_call("error_tool", "{}", call_id="2"),
),
function_tool=error_tool,
),
]
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
await execute_function_tool_calls(
bindings=bind_public_agent(agent),
tool_runs=tool_runs,
hooks=RunHooks(),
context_wrapper=context_wrapper,
config=RunConfig(),
)
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 0
state.thread_id = "thread-next"
state.events = [
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
result = await codex_function_tool.on_invoke_tool(
ToolContext(
context=run_context,
tool_name=codex_function_tool.name,
tool_call_id="call-2",
tool_arguments=input_json,
),
input_json,
)
assert isinstance(result, CodexToolResult)
assert result.thread_id == "thread-next"
assert run_context["codex_agent_thread_id"] == "thread-next"
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_falls_back_to_call_thread_id_when_thread_object_id_is_none() -> None:
state = CodexMockState()
state.thread_id = None
state.events = [
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
parameters=codex_tool_module.CodexToolParameters,
use_run_context_thread_id=True,
)
)
first_input_json = (
'{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}], '
'"thread_id": "thread-explicit"}'
)
second_input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context: dict[str, str] = {}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=first_input_json,
)
first_result = await tool.on_invoke_tool(context, first_input_json)
second_result = await tool.on_invoke_tool(context, second_input_json)
assert isinstance(first_result, CodexToolResult)
assert isinstance(second_result, CodexToolResult)
assert first_result.thread_id == "thread-explicit"
assert second_result.thread_id == "thread-explicit"
assert run_context["codex_thread_id"] == "thread-explicit"
assert state.start_calls == 0
assert state.resume_calls == 2
assert state.last_resumed_thread_id == "thread-explicit"
@pytest.mark.asyncio
async def test_codex_tool_uses_run_context_thread_id_with_pydantic_context() -> None:
class RunContext(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: str
state = CodexMockState()
state.thread_id = "thread-next"
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context = RunContext(user_id="abc")
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 1
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-next"
assert run_context.__dict__["codex_thread_id"] == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_uses_pydantic_context_field_matching_thread_id_key() -> None:
class RunContext(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: str
codex_thread_id: str | None = None
state = CodexMockState()
state.thread_id = "thread-next"
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context = RunContext(user_id="abc", codex_thread_id="thread-prev")
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 1
assert state.last_resumed_thread_id == "thread-prev"
assert run_context.codex_thread_id == "thread-next"
@pytest.mark.asyncio
async def test_codex_tool_default_run_context_key_follows_tool_name() -> None:
state = CodexMockState()
state.thread_id = "thread-next"
state.events = [
{"type": "thread.started", "thread_id": "thread-next"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
),
name="codex_engineer",
)
input_json = '{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}]}'
run_context = {"codex_thread_id_engineer": "thread-prev"}
context = ToolContext(
context=run_context,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.last_resumed_thread_id == "thread-prev"
assert run_context["codex_thread_id_engineer"] == "thread-next"
def test_codex_tool_rejects_custom_name_without_codex_prefix() -> None:
with pytest.raises(UserError, match='must be "codex" or start with "codex_"'):
codex_tool(name="engineer")
def test_codex_tool_allows_non_alnum_suffix_when_run_context_thread_id_disabled() -> None:
tool = codex_tool(name="codex_a-b")
assert tool.name == "codex_a-b"
def test_codex_tool_rejects_lossy_default_run_context_thread_id_key_suffix() -> None:
with pytest.raises(UserError, match="run_context_thread_id_key"):
codex_tool(name="codex_a-b", use_run_context_thread_id=True)
@pytest.mark.asyncio
async def test_codex_tool_tool_input_thread_id_overrides_run_context_thread_id() -> None:
state = CodexMockState()
state.thread_id = "thread-from-tool-input"
state.events = [
{"type": "thread.started", "thread_id": "thread-from-tool-input"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
parameters=codex_tool_module.CodexToolParameters,
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = (
'{"inputs": [{"type": "text", "text": "Continue thread", "path": ""}], '
'"thread_id": "thread-from-args"}'
)
context = ToolContext(
context={"codex_thread_id": "thread-from-context"},
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
await tool.on_invoke_tool(context, input_json)
assert state.last_resumed_thread_id == "thread-from-args"
def test_codex_tool_run_context_mode_hides_thread_id_in_default_parameters() -> None:
tool = codex_tool(use_run_context_thread_id=True)
assert "thread_id" not in tool.params_json_schema["properties"]
@pytest.mark.asyncio
async def test_codex_tool_duplicate_names_fail_fast() -> None:
agent = Agent(
name="test",
tools=[
codex_tool(),
codex_tool(),
],
)
with pytest.raises(UserError, match="Duplicate Codex tool names found"):
await agent.get_all_tools(RunContextWrapper(context=None))
@pytest.mark.asyncio
async def test_codex_tool_name_collision_with_other_tool_fails_fast() -> None:
@function_tool(name_override="codex")
def other_tool() -> str:
return "ok"
agent = Agent(
name="test",
tools=[
codex_tool(),
other_tool,
],
)
with pytest.raises(UserError, match="Duplicate Codex tool names found"):
await agent.get_all_tools(RunContextWrapper(context=None))
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_requires_mutable_context() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "No context", "path": ""}]}'
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="use_run_context_thread_id=True"):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_rejects_immutable_mapping_context() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Immutable context", "path": ""}]}'
context = ToolContext(
context=MappingProxyType({"codex_thread_id": "thread-prev"}),
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="use_run_context_thread_id=True"):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_rejects_frozen_pydantic_context() -> None:
class FrozenRunContext(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: str
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Frozen context", "path": ""}]}'
context = ToolContext(
context=FrozenRunContext(user_id="abc"),
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="Frozen Pydantic models"):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_rejects_frozen_dataclass_context() -> None:
@dataclass(frozen=True)
class FrozenRunContext:
user_id: str
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Frozen dataclass", "path": ""}]}'
context = ToolContext(
context=FrozenRunContext(user_id="abc"),
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="Frozen dataclass contexts"):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_rejects_slots_object_without_thread_field() -> None:
class SlotsRunContext:
__slots__ = ("user_id",)
def __init__(self, user_id: str) -> None:
self.user_id = user_id
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "Slots context", "path": ""}]}'
context = ToolContext(
context=SlotsRunContext(user_id="abc"),
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match='support field "codex_thread_id"'):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.asyncio
async def test_codex_tool_run_context_thread_id_rejects_non_writable_object_context() -> None:
state = CodexMockState()
state.events = [
{"type": "thread.started", "thread_id": "thread-1"},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "Codex done."},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
tool = codex_tool(
CodexToolOptions(
codex=cast(Codex, FakeCodex(state)),
use_run_context_thread_id=True,
failure_error_function=None,
)
)
input_json = '{"inputs": [{"type": "text", "text": "List context", "path": ""}]}'
context: ToolContext[Any] = ToolContext(
context=cast(Any, []),
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(UserError, match="use_run_context_thread_id=True"):
await tool.on_invoke_tool(context, input_json)
assert state.start_calls == 0
assert state.resume_calls == 0
@pytest.mark.parametrize(
("payload", "message"),
[
({"type": "text", "text": "", "path": ""}, 'non-empty "text"'),
({"type": "text", "text": "hello", "path": "x"}, '"path" is not allowed'),
({"type": "local_image", "path": ""}, 'non-empty "path"'),
({"type": "local_image", "path": "img.png", "text": "hi"}, '"text" is not allowed'),
],
)
def test_codex_tool_input_item_validation_errors(payload: dict[str, Any], message: str) -> None:
with pytest.raises(ValueError, match=message):
codex_tool_module.CodexToolInputItem(**payload)
def test_codex_tool_result_stringifies() -> None:
result = CodexToolResult(thread_id="thread-1", response="ok", usage=None)
assert json.loads(str(result)) == result.as_dict()
def test_codex_tool_parse_input_rejects_invalid_json() -> None:
with pytest.raises(ModelBehaviorError, match="Invalid JSON input for codex tool"):
codex_tool_module._parse_tool_input(codex_tool_module.CodexToolParameters, "{bad")
def test_codex_tool_normalize_parameters_requires_inputs() -> None:
class Dummy(BaseModel):
model_config = ConfigDict(extra="forbid")
with pytest.raises(UserError, match="must include an inputs field"):
codex_tool_module._normalize_parameters(Dummy())
def test_codex_tool_coerce_options_rejects_unknown_fields() -> None:
with pytest.raises(UserError, match="Unknown Codex tool option"):
codex_tool_module._coerce_tool_options({"unknown": "value"})
def test_codex_tool_keyword_rejects_empty_run_context_key() -> None:
with pytest.raises(UserError, match="run_context_thread_id_key"):
codex_tool(run_context_thread_id_key=" ")
def test_codex_tool_resolve_output_schema_validation_errors() -> None:
with pytest.raises(UserError, match="must include properties"):
codex_tool_module._resolve_output_schema({"properties": []})
with pytest.raises(UserError, match="Invalid schema for output property"):
codex_tool_module._resolve_output_schema(
{"properties": [{"name": "bad", "schema": {"type": "bogus"}}]}
)
with pytest.raises(UserError, match="Required property"):
codex_tool_module._resolve_output_schema(
{
"properties": [{"name": "name", "schema": {"type": "string"}}],
"required": ["missing"],
}
)
with pytest.raises(UserError, match='type "object"'):
codex_tool_module._resolve_output_schema({"type": "string"})
def test_codex_tool_resolve_output_schema_does_not_mutate_input() -> None:
nested = {"type": "object", "properties": {"y": {"type": "string"}}}
option = {"type": "object", "properties": {"inner": nested}}
option_snapshot = copy.deepcopy(option)
result = codex_tool_module._resolve_output_schema(option)
assert option == option_snapshot
assert nested == {"type": "object", "properties": {"y": {"type": "string"}}}
assert result is not None
assert result["properties"]["inner"] is not nested
def test_codex_tool_resolve_output_schema_descriptor() -> None:
descriptor = {
"title": "Report",
"description": "Structured output",
"properties": [
{
"name": "tags",
"description": "Tag list",
"schema": {
"type": "array",
"description": "Tags array",
"items": {"type": "string", "description": "Tag value"},
},
},
{
"name": "summary",
"description": "Summary text",
"schema": {"type": "string"},
},
],
"required": ["tags"],
}
schema = codex_tool_module._resolve_output_schema(descriptor)
assert schema["title"] == "Report"
assert schema["description"] == "Structured output"
assert schema["properties"]["tags"]["type"] == "array"
assert schema["properties"]["tags"]["description"] == "Tag list"
assert schema["properties"]["tags"]["items"]["description"] == "Tag value"
assert schema["properties"]["tags"]["items"]["type"] == "string"
assert schema["required"] == ["tags"]
def test_codex_tool_resolve_codex_options_reads_env_override() -> None:
options = codex_tool_module.CodexOptions(
codex_path_override="/bin/codex",
env={"CODEX_API_KEY": "env-key"},
)
resolved = codex_tool_module._resolve_codex_options(options)
assert resolved is not None
assert resolved.api_key == "env-key"
assert resolved.codex_path_override == "/bin/codex"
@pytest.mark.asyncio
async def test_codex_tool_create_codex_resolver_caches_instance() -> None:
options = codex_tool_module.CodexOptions(codex_path_override="/bin/codex")
resolver = codex_tool_module._create_codex_resolver(None, options)
first = await resolver()
second = await resolver()
assert first is second
def test_codex_tool_resolve_thread_options_merges_values() -> None:
resolved = codex_tool_module._resolve_thread_options(
{"model": "gpt-4.1-mini"},
sandbox_mode="read-only",
working_directory="/work",
skip_git_repo_check=True,
)
assert resolved is not None
assert resolved.model == "gpt-4.1-mini"
assert resolved.sandbox_mode == "read-only"
assert resolved.working_directory == "/work"
assert resolved.skip_git_repo_check is True
def test_codex_tool_resolve_thread_options_empty_is_none() -> None:
assert codex_tool_module._resolve_thread_options(None, None, None, None) is None
def test_codex_tool_build_turn_options_merges_output_schema() -> None:
output_schema = {"type": "object", "properties": {}, "additionalProperties": False}
turn = codex_tool_module._build_turn_options(None, output_schema)
assert turn.output_schema == output_schema
turn_defaults = codex_tool_module.TurnOptions(
output_schema={"type": "object", "properties": {"x": {"type": "string"}}},
idle_timeout_seconds=1.0,
)
turn = codex_tool_module._build_turn_options(turn_defaults, None)
assert turn.output_schema == turn_defaults.output_schema
assert turn.idle_timeout_seconds == 1.0
def test_codex_tool_persisted_thread_mismatch_raises() -> None:
class DummyThread:
def __init__(self, thread_id: str) -> None:
self.id = thread_id
with pytest.raises(UserError, match="already has an active thread"):
codex_tool_module._get_or_create_persisted_thread(
codex=object(),
thread_id="thread-2",
thread_options=None,
existing_thread=DummyThread("thread-1"),
)
def test_codex_tool_default_response_text() -> None:
assert (
codex_tool_module._build_default_response({"inputs": None})
== "Codex task completed with no inputs."
)
def test_codex_tool_input_item_accepts_local_image() -> None:
item = codex_tool_module.CodexToolInputItem(type="local_image", path=" /tmp/img.png ")
assert item.path == "/tmp/img.png"
assert item.text is None
def test_codex_tool_normalize_parameters_handles_local_image() -> None:
params = codex_tool_module.CodexToolParameters(
inputs=[
codex_tool_module.CodexToolInputItem(type="text", text="hello"),
codex_tool_module.CodexToolInputItem(type="local_image", path="/tmp/img.png"),
]
)
normalized = codex_tool_module._normalize_parameters(params)
assert normalized["inputs"] == [
{"type": "text", "text": "hello"},
{"type": "local_image", "path": "/tmp/img.png"},
]
assert normalized["thread_id"] is None
def test_codex_tool_input_thread_id_validation_errors() -> None:
with pytest.raises(ValueError, match="non-empty string"):
codex_tool_module.CodexToolParameters(
inputs=[codex_tool_module.CodexToolInputItem(type="text", text="hello")],
thread_id=" ",
)
def test_codex_tool_build_codex_input_empty() -> None:
assert codex_tool_module._build_codex_input({"inputs": None}) == ""
def test_codex_tool_truncate_span_string_limits() -> None:
assert codex_tool_module._truncate_span_string("hello", 0) == ""
long_value = "x" * 100
assert codex_tool_module._truncate_span_string(long_value, 3) == "xxx"
def test_codex_tool_truncate_span_value_handles_circular_reference() -> None:
value: list[Any] = []
value.append(value)
truncated = codex_tool_module._truncate_span_value(value, 1)
assert isinstance(truncated, dict)
assert truncated["truncated"] is True
def test_codex_tool_enforce_span_data_budget_zero_max() -> None:
assert codex_tool_module._enforce_span_data_budget({"output": "x"}, 0) == {}
def test_codex_tool_enforce_span_data_budget_trims_values_when_budget_tight() -> None:
data = {"command": "run", "output": "x" * 50, "arguments": "y" * 50}
base = {"command": "run", "output": "", "arguments": ""}
max_chars = codex_tool_module._json_char_size(base) + 1
trimmed = codex_tool_module._enforce_span_data_budget(data, max_chars)
assert codex_tool_module._json_char_size(trimmed) <= max_chars
assert "command" in trimmed
assert "output" in trimmed
assert "arguments" in trimmed
def test_codex_tool_enforce_span_data_budget_drops_until_base_fits() -> None:
data = {"command": "run", "output": "x" * 50}
base = {"command": "", "output": ""}
max_chars = codex_tool_module._json_char_size(base) - 1
trimmed = codex_tool_module._enforce_span_data_budget(data, max_chars)
assert not ("command" in trimmed and "output" in trimmed)
def test_codex_tool_handle_item_started_ignores_missing_id() -> None:
spans: dict[str, Any] = {}
codex_tool_module._handle_item_started({"type": "reasoning", "text": "hi"}, spans, None)
assert spans == {}
def test_codex_tool_handle_item_updated_ignores_missing_span() -> None:
codex_tool_module._handle_item_updated(
{"id": "missing", "type": "reasoning", "text": "hi"}, {}, None
)
@pytest.mark.asyncio
async def test_codex_tool_on_invoke_tool_handles_failure_error_function_sync() -> None:
def failure_error_function(_ctx: RunContextWrapper[Any], _exc: Exception) -> str:
return "handled"
tool = codex_tool(CodexToolOptions(failure_error_function=failure_error_function))
input_json = "{bad"
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
result = await tool.on_invoke_tool(context, input_json)
assert result == "handled"
@pytest.mark.asyncio
async def test_codex_tool_on_invoke_tool_handles_failure_error_function_async() -> None:
async def failure_error_function(_ctx: RunContextWrapper[Any], _exc: Exception) -> str:
return "handled-async"
tool = codex_tool(CodexToolOptions(failure_error_function=failure_error_function))
input_json = "{bad"
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
result = await tool.on_invoke_tool(context, input_json)
assert result == "handled-async"
@pytest.mark.asyncio
async def test_codex_tool_on_invoke_tool_raises_without_failure_handler() -> None:
tool = codex_tool(CodexToolOptions(failure_error_function=None))
input_json = "{bad"
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(ModelBehaviorError):
await tool.on_invoke_tool(context, input_json)
@pytest.mark.asyncio
async def test_replaced_codex_tool_normal_failure_uses_replaced_policy() -> None:
tool = dataclasses.replace(
codex_tool(CodexToolOptions()),
_failure_error_function=None,
_use_default_failure_error_function=False,
)
input_json = "{bad"
context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call-1",
tool_arguments=input_json,
)
with pytest.raises(ModelBehaviorError):
await tool.on_invoke_tool(context, input_json)
@pytest.mark.asyncio
async def test_replaced_codex_tool_preserves_codex_collision_markers() -> None:
agent = Agent(
name="test",
tools=[
dataclasses.replace(codex_tool(CodexToolOptions()), name="shared_codex_tool"),
dataclasses.replace(codex_tool(CodexToolOptions()), name="shared_codex_tool"),
],
)
with pytest.raises(UserError, match="Duplicate Codex tool names found: shared_codex_tool"):
await agent.get_all_tools(RunContextWrapper(None))
@pytest.mark.asyncio
async def test_codex_tool_consume_events_with_on_stream_error() -> None:
events = [
{
"type": "item.started",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "ls",
"status": "in_progress",
},
},
{
"type": "item.completed",
"item": {
"id": "cmd-1",
"type": "command_execution",
"command": "ls",
"status": "completed",
"exit_code": 0,
},
},
{
"type": "item.started",
"item": {
"id": "mcp-1",
"type": "mcp_tool_call",
"server": "server",
"tool": "tool",
"arguments": {"q": "x"},
"status": "in_progress",
},
},
{
"type": "item.completed",
"item": {
"id": "mcp-1",
"type": "mcp_tool_call",
"server": "server",
"tool": "tool",
"arguments": {"q": "x"},
"status": "failed",
"error": {"message": "boom"},
},
},
{
"type": "item.completed",
"item": {"id": "agent-1", "type": "agent_message", "text": "done"},
},
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
},
]
async def event_stream():
for event in events:
yield event
callbacks: list[str] = []
def on_stream(payload: CodexToolStreamEvent) -> None:
callbacks.append(payload.event.type)
if payload.event.type == "item.started":
raise RuntimeError("boom")
context = ToolContext(
context=None,
tool_name="codex",
tool_call_id="call-1",
tool_arguments="{}",
)
with trace("codex-test"):
response, usage, thread_id = await codex_tool_module._consume_events(
event_stream(),
{"inputs": [{"type": "text", "text": "hello"}]},
context,
SimpleNamespace(id="thread-1"),
on_stream,
64,
)
assert response == "done"
assert usage == Usage(input_tokens=1, cached_input_tokens=0, output_tokens=1)
assert thread_id == "thread-1"
assert "item.started" in callbacks
@pytest.mark.asyncio
async def test_codex_tool_consume_events_default_response() -> None:
events = [
{
"type": "turn.completed",
"usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1},
}
]
async def event_stream():
for event in events:
yield event
context = ToolContext(
context=None,
tool_name="codex",
tool_call_id="call-1",
tool_arguments="{}",
)
response, usage, thread_id = await codex_tool_module._consume_events(
event_stream(),
{"inputs": [{"type": "text", "text": "hello"}]},
context,
SimpleNamespace(id="thread-1"),
None,
None,
)
assert response == "Codex task completed with inputs."
assert usage == Usage(input_tokens=1, cached_input_tokens=0, output_tokens=1)
assert thread_id == "thread-1"
@pytest.mark.asyncio
async def test_codex_tool_consume_events_turn_failed() -> None:
events = [{"type": "turn.failed", "error": {"message": "boom"}}]
async def event_stream():
for event in events:
yield event
context = ToolContext(
context=None,
tool_name="codex",
tool_call_id="call-1",
tool_arguments="{}",
)
with pytest.raises(UserError, match="Codex turn failed: boom"):
await codex_tool_module._consume_events(
event_stream(),
{"inputs": [{"type": "text", "text": "hello"}]},
context,
SimpleNamespace(id="thread-1"),
None,
None,
)
@pytest.mark.asyncio
async def test_codex_tool_consume_events_error_event() -> None:
events = [{"type": "error", "message": "boom"}]
async def event_stream():
for event in events:
yield event
context = ToolContext(
context=None,
tool_name="codex",
tool_call_id="call-1",
tool_arguments="{}",
)
with pytest.raises(UserError, match="Codex stream error"):
await codex_tool_module._consume_events(
event_stream(),
{"inputs": [{"type": "text", "text": "hello"}]},
context,
SimpleNamespace(id="thread-1"),
None,
None,
)
@pytest.mark.asyncio
async def test_codex_tool_create_codex_resolver_with_provided() -> None:
state = CodexMockState()
provided = cast(Codex, FakeCodex(state))
resolver = codex_tool_module._create_codex_resolver(provided, None)
resolved = await resolver()
assert resolved is provided
def test_codex_tool_build_turn_options_overrides_schema() -> None:
output_schema = {"type": "object", "properties": {}, "additionalProperties": False}
turn_defaults = codex_tool_module.TurnOptions(
output_schema={"type": "object", "properties": {"x": {"type": "string"}}},
idle_timeout_seconds=1.0,
)
turn = codex_tool_module._build_turn_options(turn_defaults, output_schema)
assert turn.output_schema == output_schema
def test_codex_tool_resolve_codex_options_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CODEX_API_KEY", "env-key")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
resolved = codex_tool_module._resolve_codex_options(None)
assert resolved is not None
assert resolved.api_key == "env-key"
def test_codex_tool_accepts_all_keyword_overrides() -> None:
state = CodexMockState()
class CustomParams(BaseModel):
inputs: list[CodexToolInputItem]
model_config = ConfigDict(extra="forbid")
tool = codex_tool(
CodexToolOptions(codex=cast(Codex, FakeCodex(state))),
name="codex_overrides",
description="desc",
parameters=CustomParams,
output_schema={"type": "object", "properties": {}, "additionalProperties": False},
codex=cast(Codex, FakeCodex(state)),
codex_options={"api_key": "from-kwargs"},
default_thread_options={"model": "gpt"},
thread_id="thread-1",
sandbox_mode="read-only",
working_directory="/work",
skip_git_repo_check=True,
default_turn_options={"idle_timeout_seconds": 1.0},
span_data_max_chars=10,
persist_session=True,
on_stream=lambda _payload: None,
is_enabled=False,
failure_error_function=lambda _ctx, _exc: "handled",
use_run_context_thread_id=True,
run_context_thread_id_key="thread_key",
)
assert tool.name == "codex_overrides"
def test_codex_tool_coerce_options_rejects_empty_run_context_key() -> None:
with pytest.raises(UserError, match="run_context_thread_id_key"):
codex_tool_module._coerce_tool_options(
{
"use_run_context_thread_id": True,
"run_context_thread_id_key": " ",
}
)