From 295464e3770290c3d9a0bd4b94b4c81b8313a72a Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:34:10 -0500 Subject: [PATCH 01/13] feat: add agent-framework-codex package scaffolding Adds LICENSE, README.md, AGENTS.md, and pyproject.toml for the new agent-framework-codex package. Closes #4370. --- python/packages/codex/AGENTS.md | 28 +++++++++ python/packages/codex/LICENSE | 21 +++++++ python/packages/codex/README.md | 11 ++++ python/packages/codex/pyproject.toml | 94 ++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 python/packages/codex/AGENTS.md create mode 100644 python/packages/codex/LICENSE create mode 100644 python/packages/codex/README.md create mode 100644 python/packages/codex/pyproject.toml diff --git a/python/packages/codex/AGENTS.md b/python/packages/codex/AGENTS.md new file mode 100644 index 0000000000..35900edd72 --- /dev/null +++ b/python/packages/codex/AGENTS.md @@ -0,0 +1,28 @@ +# Codex Package (agent-framework-codex) + +Integration with OpenAI Codex as a managed agent (Codex SDK). + +## Main Classes + +- **`CodexAgent`** - Agent using Codex's native agent capabilities +- **`CodexAgentOptions`** - Options for Codex agent configuration +- **`CodexAgentSettings`** - Pydantic settings for configuration + +## Usage + +```python +from agent_framework_codex import CodexAgent + +agent = CodexAgent(...) +response = await agent.run("Hello") +``` + +## Import Path + +```python +from agent_framework_codex import CodexAgent +``` + +## Note + +This package is for Codex's managed agent functionality. For basic OpenAI chat, use `agent-framework-openai` instead. diff --git a/python/packages/codex/LICENSE b/python/packages/codex/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/codex/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/codex/README.md b/python/packages/codex/README.md new file mode 100644 index 0000000000..02452c0ad1 --- /dev/null +++ b/python/packages/codex/README.md @@ -0,0 +1,11 @@ +# Get Started with Microsoft Agent Framework Codex + +Please install this package via pip: + +```bash +pip install agent-framework-codex --pre +``` + +## Codex Agent + +The Codex agent enables integration with OpenAI Codex SDK, allowing you to interact with Codex's agentic coding capabilities through the Agent Framework. diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml new file mode 100644 index 0000000000..78cbfe5ced --- /dev/null +++ b/python/packages/codex/pyproject.toml @@ -0,0 +1,94 @@ +[project] +name = "agent-framework-codex" +description = "OpenAI Codex SDK integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260225" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0rc2", + "codex-sdk>=0.1.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_codex"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_codex" +test = "pytest --cov=agent_framework_codex --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" From fb08819a682f3c3e682a347c6da295887858c0c9 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:36:03 -0500 Subject: [PATCH 02/13] feat: add CodexAgent, CodexAgentOptions, CodexAgentSettings Core implementation of the agent-framework-codex package with: - CodexAgentSettings for env-based configuration - CodexAgentOptions for per-request options - CodexAgent wrapping CodexSDKClient with streaming, tools, sessions --- .../codex/agent_framework_codex/__init__.py | 17 + .../codex/agent_framework_codex/_agent.py | 719 ++++++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 python/packages/codex/agent_framework_codex/__init__.py create mode 100644 python/packages/codex/agent_framework_codex/_agent.py diff --git a/python/packages/codex/agent_framework_codex/__init__.py b/python/packages/codex/agent_framework_codex/__init__.py new file mode 100644 index 0000000000..de07a46774 --- /dev/null +++ b/python/packages/codex/agent_framework_codex/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._agent import CodexAgent, CodexAgentOptions, CodexAgentSettings + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "CodexAgent", + "CodexAgentOptions", + "CodexAgentSettings", + "__version__", +] diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py new file mode 100644 index 0000000000..05364b562e --- /dev/null +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -0,0 +1,719 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import contextlib +import logging +import sys +from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload + +from agent_framework import ( + AgentMiddlewareTypes, + AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + BaseAgent, + BaseContextProvider, + Content, + FunctionTool, + Message, + ResponseStream, + ToolTypes, + load_settings, + normalize_messages, + normalize_tools, +) +from agent_framework.exceptions import AgentException +from codex_sdk import ( + AssistantMessage, + CodexSDKClient, + ResultMessage, + SdkMcpTool, + create_sdk_mcp_server, +) +from codex_sdk import ( + CodexAgentOptions as SDKOptions, +) +from codex_sdk.types import StreamEvent, TextBlock + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # pragma: no cover + +if TYPE_CHECKING: + from codex_sdk import ( + AgentDefinition, + CanUseTool, + HookMatcher, + McpServerConfig, + PermissionMode, + SandboxSettings, + SdkBeta, + ) + + +logger = logging.getLogger("agent_framework.codex") + + +# Name of the in-process MCP server that hosts Agent Framework tools. +# FunctionTool instances are converted to SDK MCP tools and served +# through this server, as Codex CLI only supports tools via MCP. +TOOLS_MCP_SERVER_NAME = "_agent_framework_tools" + + +class CodexAgentSettings(TypedDict, total=False): + """Codex Agent settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'CODEX_AGENT_'. + + Keys: + cli_path: The path to Codex CLI executable. + model: The model to use (codex-mini-latest, gpt-5.1-codex). + cwd: The working directory for Codex CLI. + permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions). + max_turns: Maximum number of conversation turns. + max_budget_usd: Maximum budget in USD. + """ + + cli_path: str | None + model: str | None + cwd: str | None + permission_mode: str | None + max_turns: int | None + max_budget_usd: float | None + + +class CodexAgentOptions(TypedDict, total=False): + """Codex Agent-specific options.""" + + system_prompt: str + """System prompt for the agent.""" + + cli_path: str + """Path to Codex CLI executable. Default: auto-detected.""" + + cwd: str + """Working directory for Codex CLI. Default: current working directory.""" + + env: dict[str, str] + """Environment variables to pass to CLI.""" + + model: str + """Model to use ("codex-mini-latest", "gpt-5.1-codex"). Default: "codex-mini-latest".""" + + fallback_model: str + """Fallback model if primary fails.""" + + max_thinking_tokens: int + """Maximum tokens for thinking blocks.""" + + allowed_tools: list[str] + """Allowlist of tools. If set, Codex can ONLY use tools in this list.""" + + disallowed_tools: list[str] + """Blocklist of tools. Codex cannot use these tools.""" + + mcp_servers: dict[str, McpServerConfig] + """MCP server configurations for external tools.""" + + permission_mode: PermissionMode + """Permission handling mode ("default", "acceptEdits", "plan", "bypassPermissions").""" + + can_use_tool: CanUseTool + """Permission callback for tool use.""" + + max_turns: int + """Maximum conversation turns.""" + + max_budget_usd: float + """Budget limit in USD.""" + + hooks: dict[str, list[HookMatcher]] + """Pre/post tool hooks.""" + + add_dirs: list[str] + """Additional directories to add to context.""" + + sandbox: SandboxSettings + """Sandbox configuration for execution isolation.""" + + agents: dict[str, AgentDefinition] + """Custom agent definitions.""" + + output_format: dict[str, Any] + """Structured output format (JSON schema).""" + + enable_file_checkpointing: bool + """Enable file checkpointing for rewind.""" + + betas: list[SdkBeta] + """Beta features to enable.""" + + +OptionsT = TypeVar( + "OptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="CodexAgentOptions", + covariant=True, +) + + +class CodexAgent(BaseAgent, Generic[OptionsT]): + """OpenAI Codex Agent using Codex CLI. + + Wraps the Codex SDK to provide agentic coding capabilities including + tool use, session management, and streaming responses. + + This agent communicates with Codex through the Codex CLI, + enabling access to Codex's full agentic capabilities like file + editing, code execution, and tool use. + + The agent can be used as an async context manager to ensure proper cleanup: + + Examples: + Basic usage with context manager: + + .. code-block:: python + + from agent_framework_codex import CodexAgent + + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("Hello!") + print(response.text) + + With streaming: + + .. code-block:: python + + async with CodexAgent() as agent: + async for update in agent.run("Write a poem", stream=True): + print(update.text, end="", flush=True) + + With session management: + + .. code-block:: python + + async with CodexAgent() as agent: + session = agent.create_session() + await agent.run("Remember my name is Alice", session=session) + response = await agent.run("What's my name?", session=session) + # Codex will remember "Alice" from the same session + + With Agent Framework tools: + + .. code-block:: python + + from agent_framework import tool + + @tool + def greet(name: str) -> str: + \"\"\"Greet someone by name.\"\"\" + return f"Hello, {name}!" + + async with CodexAgent(tools=[greet]) as agent: + response = await agent.run("Greet Alice") + """ + + AGENT_PROVIDER_NAME: ClassVar[str] = "openai.codex" + + def __init__( + self, + instructions: str | None = None, + *, + client: CodexSDKClient | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a CodexAgent instance. + + Args: + instructions: System prompt for the agent. + + Keyword Args: + client: Optional pre-configured CodexSDKClient instance. If not provided, + a new client will be created using the other parameters. + id: Unique identifier for the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers for the agent. + middleware: List of middleware. + tools: Tools for the agent. Can be: + - Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob") + - Functions for custom tools + default_options: Default CodexAgentOptions including system_prompt, model, etc. + env_file_path: Path to .env file. + env_file_encoding: Encoding of .env file. + """ + super().__init__( + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + ) + + self._client = client + self._owns_client = client is None + + # Parse options + opts: dict[str, Any] = dict(default_options) if default_options else {} + + # Handle instructions parameter - set as system_prompt in options + if instructions is not None: + opts["system_prompt"] = instructions + + cli_path = opts.pop("cli_path", None) + model = opts.pop("model", None) + cwd = opts.pop("cwd", None) + permission_mode = opts.pop("permission_mode", None) + max_turns = opts.pop("max_turns", None) + max_budget_usd = opts.pop("max_budget_usd", None) + self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {} + + # Load settings from environment and options + self._settings = load_settings( + CodexAgentSettings, + env_prefix="CODEX_AGENT_", + cli_path=cli_path, + model=model, + cwd=cwd, + permission_mode=permission_mode, + max_turns=max_turns, + max_budget_usd=max_budget_usd, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + # Separate built-in tools (strings) from custom tools (callables/FunctionTool) + self._builtin_tools: list[str] = [] + self._custom_tools: list[ToolTypes] = [] + self._normalize_tools(tools) + + self._default_options = opts + self._started = False + self._current_session_id: str | None = None + + def _normalize_tools( + self, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None, + ) -> None: + """Separate built-in tools (strings) from custom tools. + + Args: + tools: Mixed list of tool names and custom tools. + """ + if tools is None: + return + + # Normalize to sequence + if isinstance(tools, str): + tools_list: Sequence[Any] = [tools] + elif isinstance(tools, Sequence): + tools_list = list(tools) + else: + tools_list = [tools] + + for tool in tools_list: + if isinstance(tool, str): + self._builtin_tools.append(tool) + else: + # Use normalize_tools for custom tools + normalized = normalize_tools(tool) + self._custom_tools.extend(normalized) + + async def __aenter__(self) -> CodexAgent[OptionsT]: + """Start the agent when entering async context.""" + await self.start() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Stop the agent when exiting async context.""" + await self.stop() + + async def start(self) -> None: + """Start the Codex SDK client. + + This method initializes the Codex SDK client and establishes a connection + to the Codex CLI. It is called automatically when using the agent + as an async context manager. + + Raises: + AgentException: If the client fails to start. + """ + await self._ensure_session() + + async def stop(self) -> None: + """Stop the Codex SDK client and clean up resources. + + Stops the client if owned by this agent. Called automatically when + using the agent as an async context manager. + """ + if self._client and self._owns_client: + with contextlib.suppress(Exception): + await self._client.disconnect() + + self._started = False + self._current_session_id = None + + async def _ensure_session(self, session_id: str | None = None) -> None: + """Ensure the client is connected for the specified session. + + If the requested session differs from the current one, recreates the client. + + Args: + session_id: The session ID to use, or None for a new session. + """ + needs_new_client = ( + not self._started or self._client is None or (session_id and session_id != self._current_session_id) + ) + + if needs_new_client: + # Stop existing client if any + if self._client and self._owns_client: + with contextlib.suppress(Exception): + await self._client.disconnect() + self._started = False + + # Create new client with resume option if needed + opts = self._prepare_client_options(resume_session_id=session_id) + self._client = CodexSDKClient(options=opts) + self._owns_client = True + + try: + await self._client.connect() + self._started = True + self._current_session_id = session_id + except Exception as ex: + self._client = None + raise AgentException(f"Failed to start Codex SDK client: {ex}") from ex + + def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOptions: + """Prepare SDK options for client initialization. + + Args: + resume_session_id: Optional session ID to resume. + + Returns: + SDKOptions instance configured for the client. + """ + opts: dict[str, Any] = {} + + # Set resume option if provided + if resume_session_id: + opts["resume"] = resume_session_id + + # Apply settings from environment + if cli_path := self._settings.get("cli_path"): + opts["cli_path"] = cli_path + if model := self._settings.get("model"): + opts["model"] = model + if cwd := self._settings.get("cwd"): + opts["cwd"] = cwd + if permission_mode := self._settings.get("permission_mode"): + opts["permission_mode"] = permission_mode + if max_turns := self._settings.get("max_turns"): + opts["max_turns"] = max_turns + if max_budget_usd := self._settings.get("max_budget_usd"): + opts["max_budget_usd"] = max_budget_usd + + # Apply default options + for key, value in self._default_options.items(): + if value is not None: + opts[key] = value + + # Add built-in tools (strings like "Read", "Write", "Bash") + if self._builtin_tools: + opts["tools"] = self._builtin_tools + + # Prepare custom tools (FunctionTool instances) + custom_tools_server, custom_tool_names = ( + self._prepare_tools(self._custom_tools) if self._custom_tools else (None, []) + ) + + # MCP servers - merge user-provided servers with custom tools server + mcp_servers = dict(self._mcp_servers) if self._mcp_servers else {} + if custom_tools_server: + mcp_servers[TOOLS_MCP_SERVER_NAME] = custom_tools_server + if mcp_servers: + opts["mcp_servers"] = mcp_servers + + # Add custom tools to allowed_tools so they can be executed + if custom_tool_names: + existing_allowed = opts.get("allowed_tools", []) + opts["allowed_tools"] = list(existing_allowed) + custom_tool_names + + # Always enable partial messages for streaming support + opts["include_partial_messages"] = True + + return SDKOptions(**opts) + + def _prepare_tools( + self, + tools: Sequence[ToolTypes], + ) -> tuple[Any, list[str]]: + """Convert Agent Framework tools to SDK MCP server. + + Args: + tools: List of Agent Framework tools. + + Returns: + Tuple of (MCP server config, list of allowed tool names). + """ + sdk_tools: list[SdkMcpTool[Any]] = [] + tool_names: list[str] = [] + + for tool in tools: + if isinstance(tool, FunctionTool): + sdk_tools.append(self._function_tool_to_sdk_mcp_tool(tool)) + # Codex SDK convention: MCP tools use format "mcp__{server}__{tool}" + tool_names.append(f"mcp__{TOOLS_MCP_SERVER_NAME}__{tool.name}") + else: + # Non-FunctionTool items (e.g., dict-based hosted tools) cannot be converted to SDK MCP tools + logger.debug(f"Unsupported tool type: {type(tool)}") + + if not sdk_tools: + return None, [] + + return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names + + def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool) -> SdkMcpTool[Any]: + """Convert a FunctionTool to an SDK MCP tool. + + Args: + func_tool: The FunctionTool to convert. + + Returns: + An SdkMcpTool instance. + """ + + async def handler(args: dict[str, Any]) -> dict[str, Any]: + """Handler that invokes the FunctionTool.""" + try: + if func_tool.input_model: + args_instance = func_tool.input_model(**args) + result = await func_tool.invoke(arguments=args_instance) + else: + result = await func_tool.invoke(arguments=args) + return {"content": [{"type": "text", "text": str(result)}]} + except Exception as e: + return {"content": [{"type": "text", "text": f"Error: {e}"}]} + + # Get JSON schema from pydantic model + schema: dict[str, Any] = func_tool.input_model.model_json_schema() if func_tool.input_model else {} + input_schema: dict[str, Any] = { + "type": "object", + "properties": schema.get("properties", {}), + "required": schema.get("required", []), + } + # Preserve $defs for nested type references (Pydantic uses $defs for nested models) + if "$defs" in schema: + input_schema["$defs"] = schema["$defs"] + + return SdkMcpTool( + name=func_tool.name, + description=func_tool.description, + input_schema=input_schema, + handler=handler, + ) + + async def _apply_runtime_options(self, options: dict[str, Any] | None) -> None: + """Apply runtime options that can be changed dynamically. + + The Codex SDK supports changing model and permission_mode after connection. + + Args: + options: Runtime options to apply. + """ + if not options or not self._client: + return + + if "model" in options: + await self._client.set_model(options["model"]) + + if "permission_mode" in options: + await self._client.set_permission_mode(options["permission_mode"]) + + def _format_prompt(self, messages: list[Message] | None) -> str: + """Format messages into a prompt string. + + Args: + messages: List of chat messages. + + Returns: + Formatted prompt string. + """ + if not messages: + return "" + return "\n".join([msg.text or "" for msg in messages]) + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate]: ... + + @overload + async def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, + **kwargs: Any, + ) -> AgentResponse[Any]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: + """Run the agent with the given messages. + + Args: + messages: The messages to process. + + Keyword Args: + stream: If True, returns an async iterable of updates. If False (default), + returns an awaitable AgentResponse. + session: The conversation session. If session has service_session_id set, + the agent will resume that session. + options: Runtime options (model, permission_mode can be changed per-request). + kwargs: Additional keyword arguments. + + Returns: + When stream=True: An ResponseStream for streaming updates. + When stream=False: An Awaitable[AgentResponse] with the complete response. + """ + response = ResponseStream( + self._get_stream(messages, session=session, options=options, **kwargs), + finalizer=self._finalize_response, + ) + if stream: + return response + return response.get_final_response() + + def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + """Build AgentResponse and propagate structured_output as value. + + Args: + updates: The collected stream updates. + + Returns: + An AgentResponse with structured_output set as value if present. + """ + structured_output = getattr(self, "_structured_output", None) + return AgentResponse.from_updates(updates, value=structured_output) + + async def _get_stream( + self, + messages: AgentRunInputs | None = None, + *, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate]: + """Internal streaming implementation.""" + session = session or self.create_session() + + # Ensure we're connected to the right session + await self._ensure_session(session.service_session_id) + + if not self._client: + raise RuntimeError("Codex SDK client not initialized.") + + prompt = self._format_prompt(normalize_messages(messages)) + + # Apply runtime options (model, permission_mode) + await self._apply_runtime_options(dict(options) if options else None) + + session_id: str | None = None + structured_output: Any = None + + await self._client.query(prompt) + async for message in self._client.receive_response(): + if isinstance(message, StreamEvent): + # Handle streaming events - extract text/thinking deltas + event = message.event + if event.get("type") == "content_block_delta": + delta = event.get("delta", {}) + delta_type = delta.get("type") + if delta_type == "text_delta": + text = delta.get("text", "") + if text: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text(text=text, raw_representation=message)], + raw_representation=message, + ) + elif delta_type == "thinking_delta": + thinking = delta.get("thinking", "") + if thinking: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text_reasoning(text=thinking, raw_representation=message)], + raw_representation=message, + ) + elif isinstance(message, AssistantMessage): + # Handle AssistantMessage - check for API errors + # Note: In streaming mode, the content was already yielded via StreamEvent, + # so we only check for errors here, not re-emit content. + if message.error: + # Map error types to descriptive messages + error_messages = { + "authentication_failed": "Authentication failed with Codex API", + "billing_error": "Billing error with Codex API", + "rate_limit": "Rate limit exceeded for Codex API", + "invalid_request": "Invalid request to Codex API", + "server_error": "Codex API server error", + "unknown": "Unknown error from Codex API", + } + error_msg = error_messages.get(message.error, f"Codex API error: {message.error}") + # Extract any error details from content blocks + if message.content: + for block in message.content: + if isinstance(block, TextBlock): + error_msg = f"{error_msg}: {block.text}" + break + raise AgentException(error_msg) + elif isinstance(message, ResultMessage): + # Check for errors in result message + if message.is_error: + error_msg = message.result or "Unknown error from Codex API" + raise AgentException(f"Codex API error: {error_msg}") + session_id = message.session_id + structured_output = message.structured_output + + # Update session with session ID + if session_id: + session.service_session_id = session_id + + # Store structured output for the finalizer + self._structured_output = structured_output From 57c05cfac8bb5b76a1c88eb8dc6ba372a5458102 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:38:00 -0500 Subject: [PATCH 03/13] test: add comprehensive unit tests for CodexAgent Tests cover settings, initialization, lifecycle, run (streaming and non-streaming), session management, tool conversion, permissions, error handling, format prompt, options preparation, and structured output. --- .../packages/codex/tests/test_codex_agent.py | 881 ++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 python/packages/codex/tests/test_codex_agent.py diff --git a/python/packages/codex/tests/test_codex_agent.py b/python/packages/codex/tests/test_codex_agent.py new file mode 100644 index 0000000000..7c34b18219 --- /dev/null +++ b/python/packages/codex/tests/test_codex_agent.py @@ -0,0 +1,881 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool +from agent_framework._settings import load_settings + +from agent_framework_codex import CodexAgent, CodexAgentOptions, CodexAgentSettings +from agent_framework_codex._agent import TOOLS_MCP_SERVER_NAME + +# region Test CodexAgentSettings + + +class TestCodexAgentSettings: + """Tests for CodexAgentSettings.""" + + def test_default_values(self) -> None: + """Test default values are None.""" + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["cli_path"] is None + assert settings["model"] is None + assert settings["cwd"] is None + assert settings["permission_mode"] is None + assert settings["max_turns"] is None + assert settings["max_budget_usd"] is None + + def test_explicit_values(self) -> None: + """Test explicit values override defaults.""" + settings = load_settings( + CodexAgentSettings, + env_prefix="CODEX_AGENT_", + cli_path="/usr/local/bin/codex", + model="codex-mini-latest", + cwd="/home/user/project", + permission_mode="default", + max_turns=10, + max_budget_usd=5.0, + ) + assert settings["cli_path"] == "/usr/local/bin/codex" + assert settings["model"] == "codex-mini-latest" + assert settings["cwd"] == "/home/user/project" + assert settings["permission_mode"] == "default" + assert settings["max_turns"] == 10 + assert settings["max_budget_usd"] == 5.0 + + def test_env_variable_loading(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test loading from environment variables.""" + monkeypatch.setenv("CODEX_AGENT_MODEL", "gpt-5.1-codex") + monkeypatch.setenv("CODEX_AGENT_MAX_TURNS", "20") + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["model"] == "gpt-5.1-codex" + assert settings["max_turns"] == 20 + + +# region Test CodexAgent Initialization + + +class TestCodexAgentInit: + """Tests for CodexAgent initialization.""" + + def test_default_initialization(self) -> None: + """Test agent initializes with defaults.""" + agent = CodexAgent() + assert agent.id is not None + assert agent.name is None + assert agent.description is None + + def test_with_name_and_description(self) -> None: + """Test agent with name and description.""" + agent = CodexAgent(name="test-agent", description="A test agent") + assert agent.name == "test-agent" + assert agent.description == "A test agent" + + def test_with_instructions_parameter(self) -> None: + """Test agent with instructions parameter.""" + agent = CodexAgent(instructions="You are a helpful assistant.") + assert agent._default_options.get("system_prompt") == "You are a helpful assistant." # type: ignore[reportPrivateUsage] + + def test_with_system_prompt_in_options(self) -> None: + """Test agent with system_prompt in options.""" + options: CodexAgentOptions = { + "system_prompt": "You are a helpful assistant.", + } + agent = CodexAgent(default_options=options) + assert agent._default_options.get("system_prompt") == "You are a helpful assistant." # type: ignore[reportPrivateUsage] + + def test_with_default_options(self) -> None: + """Test agent with default options.""" + options: CodexAgentOptions = { + "model": "codex-mini-latest", + "permission_mode": "default", + "max_turns": 10, + } + agent = CodexAgent(default_options=options) + assert agent._settings["model"] == "codex-mini-latest" # type: ignore[reportPrivateUsage] + assert agent._settings["permission_mode"] == "default" # type: ignore[reportPrivateUsage] + assert agent._settings["max_turns"] == 10 # type: ignore[reportPrivateUsage] + + def test_with_function_tool(self) -> None: + """Test agent with function tool.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=[greet]) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_with_single_tool(self) -> None: + """Test agent with single tool (not in list).""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=greet) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_with_builtin_tools(self) -> None: + """Test agent with built-in tool names.""" + agent = CodexAgent(tools=["Read", "Write", "Bash"]) + assert agent._builtin_tools == ["Read", "Write", "Bash"] # type: ignore[reportPrivateUsage] + assert agent._custom_tools == [] # type: ignore[reportPrivateUsage] + + def test_with_mixed_tools(self) -> None: + """Test agent with both built-in and custom tools.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=["Read", greet, "Bash"]) + assert agent._builtin_tools == ["Read", "Bash"] # type: ignore[reportPrivateUsage] + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + +# region Test CodexAgent Lifecycle + + +class TestCodexAgentLifecycle: + """Tests for CodexAgent tool initialization.""" + + def test_custom_tools_stored_from_constructor(self) -> None: + """Test that custom tools from constructor are stored.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=[greet]) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_multiple_custom_tools(self) -> None: + """Test agent with multiple custom tools.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + @tool + def farewell(name: str) -> str: + """Say goodbye.""" + return f"Goodbye, {name}!" + + agent = CodexAgent(tools=[greet, farewell]) + assert len(agent._custom_tools) == 2 # type: ignore[reportPrivateUsage] + + def test_no_tools(self) -> None: + """Test agent without tools.""" + agent = CodexAgent() + assert agent._custom_tools == [] # type: ignore[reportPrivateUsage] + assert agent._builtin_tools == [] # type: ignore[reportPrivateUsage] + + +# region Test CodexAgent Run + + +class TestCodexAgentRun: + """Tests for CodexAgent run method.""" + + @staticmethod + async def _create_async_generator(items: list[Any]) -> Any: + """Helper to create async generator from list.""" + for item in items: + yield item + + def _create_mock_client(self, messages: list[Any]) -> MagicMock: + """Create a mock CodexSDKClient that yields given messages.""" + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.query = AsyncMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) + return mock_client + + async def test_run_with_string_message(self) -> None: + """Test run with string message.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Hello!"}, + }, + uuid="event-1", + session_id="session-123", + ), + AssistantMessage( + content=[TextBlock(text="Hello!")], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="session-123", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + response = await agent.run("Hello") + assert response.text == "Hello!" + + async def test_run_captures_session_id(self) -> None: + """Test that session ID is captured from ResultMessage.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Response"}, + }, + uuid="event-1", + session_id="test-session-id", + ), + AssistantMessage( + content=[TextBlock(text="Response")], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="test-session-id", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + session = agent.create_session() + await agent.run("Hello", session=session) + assert session.service_session_id == "test-session-id" + + async def test_run_with_session(self) -> None: + """Test run with existing session.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Response"}, + }, + uuid="event-1", + session_id="session-123", + ), + AssistantMessage( + content=[TextBlock(text="Response")], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="session-123", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + session = agent.create_session() + session.service_session_id = "existing-session" + await agent.run("Hello", session=session) + + +# region Test CodexAgent Run Stream + + +class TestCodexAgentRunStream: + """Tests for CodexAgent streaming run method.""" + + @staticmethod + async def _create_async_generator(items: list[Any]) -> Any: + """Helper to create async generator from list.""" + for item in items: + yield item + + def _create_mock_client(self, messages: list[Any]) -> MagicMock: + """Create a mock CodexSDKClient that yields given messages.""" + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.query = AsyncMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) + return mock_client + + async def test_run_stream_yields_updates(self) -> None: + """Test run(stream=True) yields AgentResponseUpdate objects.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Streaming "}, + }, + uuid="event-1", + session_id="stream-session", + ), + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "response"}, + }, + uuid="event-2", + session_id="stream-session", + ), + AssistantMessage( + content=[TextBlock(text="Streaming response")], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="stream-session", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + # StreamEvent yields text deltas (2 events) + assert len(updates) == 2 + assert updates[0].role == "assistant" + assert updates[0].text == "Streaming " + assert updates[1].text == "response" + + async def test_run_stream_raises_on_assistant_message_error(self) -> None: + """Test run raises AgentException when AssistantMessage has an error.""" + from agent_framework.exceptions import AgentException + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + + messages = [ + AssistantMessage( + content=[TextBlock(text="Error details from API")], + model="codex-mini-latest", + error="invalid_request", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="error-session", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + with pytest.raises(AgentException) as exc_info: + async for _ in agent.run("Hello", stream=True): + pass + assert "Invalid request to Codex API" in str(exc_info.value) + assert "Error details from API" in str(exc_info.value) + + async def test_run_stream_raises_on_result_message_error(self) -> None: + """Test run raises AgentException when ResultMessage.is_error is True.""" + from agent_framework.exceptions import AgentException + from codex_sdk import ResultMessage + + messages = [ + ResultMessage( + subtype="error", + duration_ms=100, + duration_api_ms=50, + is_error=True, + num_turns=0, + session_id="error-session", + result="Model 'codex-mini-latest' not found", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + with pytest.raises(AgentException) as exc_info: + async for _ in agent.run("Hello", stream=True): + pass + assert "Model 'codex-mini-latest' not found" in str(exc_info.value) + + +# region Test CodexAgent Session Management + + +class TestCodexAgentSessionManagement: + """Tests for CodexAgent session management.""" + + def test_create_session(self) -> None: + """Test create_session creates a new session.""" + agent = CodexAgent() + session = agent.create_session() + assert isinstance(session, AgentSession) + assert session.service_session_id is None + + def test_create_session_with_service_session_id(self) -> None: + """Test create_session with existing service_session_id.""" + agent = CodexAgent() + session = agent.create_session(session_id="existing-session-123") + assert isinstance(session, AgentSession) + + async def test_ensure_session_creates_client(self) -> None: + """Test _ensure_session creates client when not started.""" + with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client_class.return_value = mock_client + + agent = CodexAgent() + await agent._ensure_session(None) # type: ignore[reportPrivateUsage] + + assert agent._started # type: ignore[reportPrivateUsage] + mock_client.connect.assert_called_once() + + async def test_ensure_session_recreates_for_different_session(self) -> None: + """Test _ensure_session recreates client for different session ID.""" + with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: + mock_client1 = MagicMock() + mock_client1.connect = AsyncMock() + mock_client1.disconnect = AsyncMock() + + mock_client2 = MagicMock() + mock_client2.connect = AsyncMock() + + mock_client_class.side_effect = [mock_client1, mock_client2] + + agent = CodexAgent() + + # First session + await agent._ensure_session(None) # type: ignore[reportPrivateUsage] + assert agent._started # type: ignore[reportPrivateUsage] + + # Different session should recreate client + await agent._ensure_session("new-session-id") # type: ignore[reportPrivateUsage] + assert agent._current_session_id == "new-session-id" # type: ignore[reportPrivateUsage] + mock_client1.disconnect.assert_called_once() + + async def test_ensure_session_reuses_for_same_session(self) -> None: + """Test _ensure_session reuses client for same session ID.""" + with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client_class.return_value = mock_client + + agent = CodexAgent() + + # First call + await agent._ensure_session("session-123") # type: ignore[reportPrivateUsage] + + # Same session should not recreate + await agent._ensure_session("session-123") # type: ignore[reportPrivateUsage] + + # Only called once + assert mock_client_class.call_count == 1 + + +# region Test CodexAgent Tool Conversion + + +class TestCodexAgentToolConversion: + """Tests for CodexAgent tool conversion.""" + + def test_prepare_tools_creates_mcp_server(self) -> None: + """Test _prepare_tools creates MCP server for AF tools.""" + + @tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + agent = CodexAgent(tools=[add]) + server, tool_names = agent._prepare_tools(agent._custom_tools) # type: ignore[reportPrivateUsage] + + assert server is not None + assert len(tool_names) == 1 + assert tool_names[0] == f"mcp__{TOOLS_MCP_SERVER_NAME}__add" + + def test_function_tool_to_sdk_mcp_tool(self) -> None: + """Test converting FunctionTool to SDK MCP tool.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(greet) # type: ignore[reportPrivateUsage] + + assert sdk_tool.name == "greet" + assert sdk_tool.description == "Greet someone." + assert sdk_tool.input_schema is not None + assert "properties" in sdk_tool.input_schema # type: ignore[operator] + + def test_function_tool_to_sdk_mcp_tool_preserves_defs_for_nested_types(self) -> None: + """Test that $defs is preserved for tools with nested Pydantic models.""" + from pydantic import BaseModel + + class Address(BaseModel): + street: str + city: str + + class Person(BaseModel): + name: str + address: Address + + @tool + def create_person(person: Person) -> str: + """Create a person with address.""" + return f"{person.name} lives at {person.address.street}, {person.address.city}" + + agent = CodexAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(create_person) # type: ignore[reportPrivateUsage] + + # Verify $defs is preserved in the schema + assert sdk_tool.input_schema is not None + assert "$defs" in sdk_tool.input_schema # type: ignore[operator] + assert "Address" in sdk_tool.input_schema["$defs"] # type: ignore[index] + # Verify the nested reference exists in properties + assert "person" in sdk_tool.input_schema["properties"] # type: ignore[index] + + async def test_tool_handler_success(self) -> None: + """Test tool handler executes successfully.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(greet) # type: ignore[reportPrivateUsage] + + result = await sdk_tool.handler({"name": "World"}) + assert result["content"][0]["text"] == "Hello, World!" + + async def test_tool_handler_error(self) -> None: + """Test tool handler handles errors.""" + + @tool + def failing_tool() -> str: + """A tool that fails.""" + raise ValueError("Something went wrong") + + agent = CodexAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(failing_tool) # type: ignore[reportPrivateUsage] + + result = await sdk_tool.handler({}) + assert "Error:" in result["content"][0]["text"] + assert "Something went wrong" in result["content"][0]["text"] + + +# region Test CodexAgent Permissions + + +class TestCodexAgentPermissions: + """Tests for CodexAgent permission handling.""" + + def test_default_permission_mode(self) -> None: + """Test default permission mode.""" + agent = CodexAgent() + assert agent._settings["permission_mode"] is None # type: ignore[reportPrivateUsage] + + def test_permission_mode_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test permission mode from environment settings.""" + monkeypatch.setenv("CODEX_AGENT_PERMISSION_MODE", "acceptEdits") + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["permission_mode"] == "acceptEdits" + + def test_permission_mode_in_options(self) -> None: + """Test permission mode in options.""" + options: CodexAgentOptions = { + "permission_mode": "bypassPermissions", + } + agent = CodexAgent(default_options=options) + assert agent._settings["permission_mode"] == "bypassPermissions" # type: ignore[reportPrivateUsage] + + +# region Test CodexAgent Error Handling + + +class TestCodexAgentErrorHandling: + """Tests for CodexAgent error handling.""" + + @staticmethod + async def _empty_gen() -> Any: + """Empty async generator.""" + if False: + yield + + async def test_handles_empty_response(self) -> None: + """Test handling of empty response.""" + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.query = AsyncMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + mock_client.receive_response = MagicMock(return_value=self._empty_gen()) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + response = await agent.run("Hello") + assert response.messages == [] + + +# region Test Format Prompt + + +class TestFormatPrompt: + """Tests for _format_prompt method.""" + + def test_format_empty_messages(self) -> None: + """Test formatting empty messages.""" + agent = CodexAgent() + result = agent._format_prompt([]) # type: ignore[reportPrivateUsage] + assert result == "" + + def test_format_none_messages(self) -> None: + """Test formatting None messages.""" + agent = CodexAgent() + result = agent._format_prompt(None) # type: ignore[reportPrivateUsage] + assert result == "" + + def test_format_user_message(self) -> None: + """Test formatting user message.""" + agent = CodexAgent() + msg = Message( + role="user", + contents=[Content.from_text(text="Hello")], + ) + result = agent._format_prompt([msg]) # type: ignore[reportPrivateUsage] + assert "Hello" in result + + def test_format_multiple_messages(self) -> None: + """Test formatting multiple messages.""" + agent = CodexAgent() + messages = [ + Message(role="user", contents=[Content.from_text(text="Hi")]), + Message(role="assistant", contents=[Content.from_text(text="Hello!")]), + Message(role="user", contents=[Content.from_text(text="How are you?")]), + ] + result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] + assert "Hi" in result + assert "Hello!" in result + assert "How are you?" in result + + +# region Test Build Options + + +class TestPrepareClientOptions: + """Tests for _prepare_client_options method.""" + + def test_prepare_client_options_with_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test preparing options with settings.""" + monkeypatch.setenv("CODEX_AGENT_MODEL", "gpt-5.1-codex") + monkeypatch.setenv("CODEX_AGENT_MAX_TURNS", "15") + + agent = CodexAgent() + + with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: + mock_opts.return_value = MagicMock() + agent._prepare_client_options() # type: ignore[reportPrivateUsage] + call_kwargs = mock_opts.call_args[1] + assert call_kwargs.get("model") == "gpt-5.1-codex" + assert call_kwargs.get("max_turns") == 15 + + def test_prepare_client_options_with_instructions(self) -> None: + """Test building options with instructions parameter.""" + agent = CodexAgent(instructions="Be helpful") + + with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: + mock_opts.return_value = MagicMock() + agent._prepare_client_options() # type: ignore[reportPrivateUsage] + call_kwargs = mock_opts.call_args[1] + assert call_kwargs.get("system_prompt") == "Be helpful" + + def test_prepare_client_options_includes_custom_tools(self) -> None: + """Test that _prepare_client_options includes custom tools MCP server.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=[greet]) + + with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: + mock_opts.return_value = MagicMock() + agent._prepare_client_options() # type: ignore[reportPrivateUsage] + call_kwargs = mock_opts.call_args[1] + assert "mcp_servers" in call_kwargs + assert TOOLS_MCP_SERVER_NAME in call_kwargs["mcp_servers"] + + +class TestApplyRuntimeOptions: + """Tests for _apply_runtime_options method.""" + + async def test_apply_runtime_model(self) -> None: + """Test applying runtime model option.""" + mock_client = MagicMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + + agent = CodexAgent() + agent._client = mock_client # type: ignore[reportPrivateUsage] + + await agent._apply_runtime_options({"model": "gpt-5.1-codex"}) # type: ignore[reportPrivateUsage] + mock_client.set_model.assert_called_once_with("gpt-5.1-codex") + + async def test_apply_runtime_permission_mode(self) -> None: + """Test applying runtime permission_mode option.""" + mock_client = MagicMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + + agent = CodexAgent() + agent._client = mock_client # type: ignore[reportPrivateUsage] + + await agent._apply_runtime_options({"permission_mode": "acceptEdits"}) # type: ignore[reportPrivateUsage] + mock_client.set_permission_mode.assert_called_once_with("acceptEdits") + + async def test_apply_runtime_options_none(self) -> None: + """Test applying None options does nothing.""" + mock_client = MagicMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + + agent = CodexAgent() + agent._client = mock_client # type: ignore[reportPrivateUsage] + + await agent._apply_runtime_options(None) # type: ignore[reportPrivateUsage] + mock_client.set_model.assert_not_called() + mock_client.set_permission_mode.assert_not_called() + + +# region Test CodexAgent Structured Output + + +class TestCodexAgentStructuredOutput: + """Tests for CodexAgent structured output propagation.""" + + @staticmethod + async def _create_async_generator(items: list[Any]) -> Any: + """Helper to create async generator from list.""" + for item in items: + yield item + + def _create_mock_client(self, messages: list[Any]) -> MagicMock: + """Create a mock CodexSDKClient that yields given messages.""" + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.query = AsyncMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) + return mock_client + + async def test_structured_output_propagated_to_response(self) -> None: + """Test that structured_output from ResultMessage is propagated to response.value.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + structured_data = {"name": "Alice", "age": 30} + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": '{"name": "Alice", "age": 30}'}, + }, + uuid="event-1", + session_id="session-123", + ), + AssistantMessage( + content=[TextBlock(text='{"name": "Alice", "age": 30}')], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="session-123", + structured_output=structured_data, + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + response = await agent.run("Return structured data") + assert response.value == structured_data + + async def test_structured_output_none_when_not_present(self) -> None: + """Test that response.value is None when structured_output is not present.""" + from codex_sdk import AssistantMessage, ResultMessage, TextBlock + from codex_sdk.types import StreamEvent + + messages = [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Hello!"}, + }, + uuid="event-1", + session_id="session-123", + ), + AssistantMessage( + content=[TextBlock(text="Hello!")], + model="codex-mini-latest", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="session-123", + ), + ] + mock_client = self._create_mock_client(messages) + + with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + agent = CodexAgent() + response = await agent.run("Hello") + assert response.value is None From 140e3f66a176bb7a377db5444c1f1b05c85ad310 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:46:04 -0500 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20AGENTS.md=20settings=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- python/packages/codex/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/codex/AGENTS.md b/python/packages/codex/AGENTS.md index 35900edd72..ca15e6e7db 100644 --- a/python/packages/codex/AGENTS.md +++ b/python/packages/codex/AGENTS.md @@ -6,7 +6,7 @@ Integration with OpenAI Codex as a managed agent (Codex SDK). - **`CodexAgent`** - Agent using Codex's native agent capabilities - **`CodexAgentOptions`** - Options for Codex agent configuration -- **`CodexAgentSettings`** - Pydantic settings for configuration +- **`CodexAgentSettings`** - TypedDict-based settings populated via the framework's `load_settings()` helper ## Usage From f88b138b0e88fd039072e5ff1f137043730071bf Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:46:25 -0500 Subject: [PATCH 05/13] fix: align agent-framework-core dependency to >=1.0.0rc1 Co-authored-by: Copilot --- python/packages/codex/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml index 78cbfe5ced..73fd117642 100644 --- a/python/packages/codex/pyproject.toml +++ b/python/packages/codex/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc2", + "agent-framework-core>=1.0.0rc1", "codex-sdk>=0.1.0", ] From cd054d2ede01e63399c86622c653187e9295bb1e Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:48:11 -0500 Subject: [PATCH 06/13] fix: treat None as distinct session in _ensure_session Previously, _ensure_session() only recreated the client when (session_id and session_id != self._current_session_id), which meant switching from a resumed session back to a fresh session (None) would incorrectly reuse the existing client. Now we compare session_id != self._current_session_id directly, so None is treated as a valid, distinct session identity. Co-authored-by: Copilot --- python/packages/codex/agent_framework_codex/_agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py index 05364b562e..30d3f168ce 100644 --- a/python/packages/codex/agent_framework_codex/_agent.py +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -377,12 +377,16 @@ async def _ensure_session(self, session_id: str | None = None) -> None: """Ensure the client is connected for the specified session. If the requested session differs from the current one, recreates the client. + Treats None as a distinct session identity so that switching from a resumed + session back to a fresh session correctly creates a new client. Args: session_id: The session ID to use, or None for a new session. """ needs_new_client = ( - not self._started or self._client is None or (session_id and session_id != self._current_session_id) + not self._started + or self._client is None + or session_id != self._current_session_id ) if needs_new_client: From d07f02502993f6bc01fdda001750b073598bdb7e Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Sun, 1 Mar 2026 14:50:03 -0500 Subject: [PATCH 07/13] test: add regression test for resumed-then-fresh session isolation Verifies that _ensure_session creates a new client when switching from a resumed session (non-None) back to a fresh session (None). Co-authored-by: Copilot --- .../packages/codex/tests/test_codex_agent.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/python/packages/codex/tests/test_codex_agent.py b/python/packages/codex/tests/test_codex_agent.py index 7c34b18219..7ae7feba65 100644 --- a/python/packages/codex/tests/test_codex_agent.py +++ b/python/packages/codex/tests/test_codex_agent.py @@ -507,6 +507,36 @@ async def test_ensure_session_reuses_for_same_session(self) -> None: # Only called once assert mock_client_class.call_count == 1 + async def test_ensure_session_recreates_when_resumed_then_fresh(self) -> None: + """Test _ensure_session creates a new client when switching from a resumed session to a fresh one (None). + + Regression test: previously, _ensure_session only recreated the client when + (session_id and session_id != self._current_session_id), so a transition from + a named session back to None would silently reuse the old client. + """ + with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: + mock_client1 = MagicMock() + mock_client1.connect = AsyncMock() + mock_client1.disconnect = AsyncMock() + + mock_client2 = MagicMock() + mock_client2.connect = AsyncMock() + + mock_client_class.side_effect = [mock_client1, mock_client2] + + agent = CodexAgent() + + # Start with a resumed session + await agent._ensure_session("resumed-session-id") # type: ignore[reportPrivateUsage] + assert agent._current_session_id == "resumed-session-id" # type: ignore[reportPrivateUsage] + assert mock_client_class.call_count == 1 + + # Switch to a fresh session (None) — must create a new client + await agent._ensure_session(None) # type: ignore[reportPrivateUsage] + assert agent._current_session_id is None # type: ignore[reportPrivateUsage] + assert mock_client_class.call_count == 2 + mock_client1.disconnect.assert_called_once() + # region Test CodexAgent Tool Conversion From 4334d56de26c21cba67c9eab8137df80c2bac506 Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:20:56 -0500 Subject: [PATCH 08/13] refactor: address eavanvalkenburg review feedback - Rebase onto latest main - Bump agent-framework-core dependency to >=1.0.0rc2 - Split CodexAgent into RawCodexAgent + CodexAgent(AgentTelemetryLayer) following A2AAgent pattern as requested - Remove MutableMapping from run() options parameter type - Export RawCodexAgent from __init__.py --- .../codex/agent_framework_codex/__init__.py | 3 +- .../codex/agent_framework_codex/_agent.py | 28 +++++++++++++------ python/packages/codex/pyproject.toml | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/python/packages/codex/agent_framework_codex/__init__.py b/python/packages/codex/agent_framework_codex/__init__.py index de07a46774..eac7165458 100644 --- a/python/packages/codex/agent_framework_codex/__init__.py +++ b/python/packages/codex/agent_framework_codex/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import CodexAgent, CodexAgentOptions, CodexAgentSettings +from ._agent import CodexAgent, CodexAgentOptions, CodexAgentSettings, RawCodexAgent try: __version__ = importlib.metadata.version(__name__) @@ -13,5 +13,6 @@ "CodexAgent", "CodexAgentOptions", "CodexAgentSettings", + "RawCodexAgent", "__version__", ] diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py index 30d3f168ce..6402ae47b5 100644 --- a/python/packages/codex/agent_framework_codex/_agent.py +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -5,7 +5,7 @@ import contextlib import logging import sys -from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload from agent_framework import ( @@ -25,6 +25,7 @@ normalize_messages, normalize_tools, ) +from agent_framework.observability import AgentTelemetryLayer from agent_framework.exceptions import AgentException from codex_sdk import ( AssistantMessage, @@ -167,7 +168,7 @@ class CodexAgentOptions(TypedDict, total=False): ) -class CodexAgent(BaseAgent, Generic[OptionsT]): +class RawCodexAgent(BaseAgent, Generic[OptionsT]): """OpenAI Codex Agent using Codex CLI. Wraps the Codex SDK to provide agentic coding capabilities including @@ -238,7 +239,7 @@ def __init__( context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, - default_options: OptionsT | MutableMapping[str, Any] | None = None, + default_options: OptionsT | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -339,7 +340,7 @@ def _normalize_tools( normalized = normalize_tools(tool) self._custom_tools.extend(normalized) - async def __aenter__(self) -> CodexAgent[OptionsT]: + async def __aenter__(self) -> RawCodexAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -575,7 +576,7 @@ def run( *, stream: Literal[True], session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: ... @@ -586,7 +587,7 @@ async def run( *, stream: Literal[False] = ..., session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AgentResponse[Any]: ... @@ -596,7 +597,7 @@ def run( *, stream: bool = False, session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: """Run the agent with the given messages. @@ -641,7 +642,7 @@ async def _get_stream( messages: AgentRunInputs | None = None, *, session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal streaming implementation.""" @@ -721,3 +722,14 @@ async def _get_stream( # Store structured output for the finalizer self._structured_output = structured_output + + +class CodexAgent(AgentTelemetryLayer, RawCodexAgent[OptionsT], Generic[OptionsT]): + """OpenAI Codex Agent with built-in OpenTelemetry instrumentation. + + Extends :class:`RawCodexAgent` with automatic telemetry spans via + :class:`AgentTelemetryLayer`. Use ``RawCodexAgent`` directly if you + need the agent without telemetry overhead. + """ + + pass diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml index 73fd117642..78cbfe5ced 100644 --- a/python/packages/codex/pyproject.toml +++ b/python/packages/codex/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "codex-sdk>=0.1.0", ] From b817a449bb64806207126daf6d645fe9dfd91269 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Tue, 3 Mar 2026 07:08:34 -0500 Subject: [PATCH 09/13] Address eavanvalkenburg review feedback - Simplify typing: remove MutableMapping union, use just OptionsT - Update dependency to agent-framework-core>=1.0.0rc2 - Change AGENT_PROVIDER_NAME from "openai.codex" to "openai" per OTel spec - Add sample script showcasing basic CodexAgent usage --- .../codex/agent_framework_codex/_agent.py | 2 +- python/packages/codex/samples/codex_sample.py | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 python/packages/codex/samples/codex_sample.py diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py index 6402ae47b5..2d758b0a4a 100644 --- a/python/packages/codex/agent_framework_codex/_agent.py +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -226,7 +226,7 @@ def greet(name: str) -> str: response = await agent.run("Greet Alice") """ - AGENT_PROVIDER_NAME: ClassVar[str] = "openai.codex" + AGENT_PROVIDER_NAME: ClassVar[str] = "openai" def __init__( self, diff --git a/python/packages/codex/samples/codex_sample.py b/python/packages/codex/samples/codex_sample.py new file mode 100644 index 0000000000..fd5ca97529 --- /dev/null +++ b/python/packages/codex/samples/codex_sample.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Sample demonstrating CodexAgent usage with Agent Framework. + +This sample shows how to use the CodexAgent for: +- Basic non-streaming interaction +- Streaming responses +- Multi-turn conversations with sessions +- Custom tool integration + +Prerequisites: + pip install agent-framework-codex + export OPENAI_API_KEY="your-api-key" +""" + +import asyncio + +from agent_framework import tool + +from agent_framework_codex import CodexAgent + + +@tool +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + # Stub implementation for demonstration + return f"The weather in {city} is 72°F and sunny." + + +async def basic_usage() -> None: + """Run a simple non-streaming query.""" + print("=== Basic Usage ===") + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("What is a Python list comprehension? Give a short example.") + print(response.text) + print() + + +async def streaming_usage() -> None: + """Stream a response token-by-token.""" + print("=== Streaming ===") + async with CodexAgent() as agent: + async for update in agent.run( + "Write a short haiku about coding.", + stream=True, + ): + print(update.text, end="", flush=True) + print("\n") + + +async def session_usage() -> None: + """Demonstrate multi-turn conversation using sessions.""" + print("=== Session (Multi-turn) ===") + async with CodexAgent() as agent: + session = agent.create_session() + + await agent.run("My name is Alice and I'm working on a FastAPI project.", session=session) + print("(context set)") + + response = await agent.run("What's my name and what am I working on?", session=session) + print(response.text) + print() + + +async def tool_usage() -> None: + """Demonstrate custom tool integration.""" + print("=== Custom Tools ===") + async with CodexAgent( + instructions="Use the get_weather tool when asked about weather.", + tools=[get_weather], + ) as agent: + response = await agent.run("What's the weather like in Seattle?") + print(response.text) + print() + + +async def main() -> None: + """Run all samples.""" + await basic_usage() + await streaming_usage() + await session_usage() + await tool_usage() + + +if __name__ == "__main__": + asyncio.run(main()) From cdee60b771a3a5fb5a4ebacfd72b577857e0e910 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Fri, 10 Apr 2026 15:12:42 -0400 Subject: [PATCH 10/13] Python: Apply alpha package management standards to agent-framework-codex Per maintainer guidance and python-package-management SKILL.md: - Update version from 1.0.0b260225 to 1.0.0a260410 (alpha pattern) - Update classifier from Beta to Alpha (Development Status :: 3 - Alpha) - Update agent-framework-core dependency from >=1.0.0rc2 to >=1.0.1,<2 - Add upper bound to codex-sdk: >=0.1.0,<0.2 - Add pyright include directive for agent_framework_codex - Use structured poe task format with help + cmd keys - Exclude integration tests from default test run - Register package in root pyproject.toml tool.uv.sources - Add codex to AGENTS.md package documentation index --- python/AGENTS.md | 1 + python/packages/codex/pyproject.toml | 19 ++++++++++++------- python/pyproject.toml | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/python/AGENTS.md b/python/AGENTS.md index e4697e18d5..0b8ff4a5aa 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -64,6 +64,7 @@ python/ - [anthropic](packages/anthropic/AGENTS.md) - Anthropic Claude API - [bedrock](packages/bedrock/AGENTS.md) - AWS Bedrock - [claude](packages/claude/AGENTS.md) - Claude Agent SDK +- [codex](packages/codex/AGENTS.md) - OpenAI Codex SDK - [foundry_local](packages/foundry_local/AGENTS.md) - Azure AI Foundry Local - [ollama](packages/ollama/AGENTS.md) - Local Ollama inference diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml index 78cbfe5ced..548d724e20 100644 --- a/python/packages/codex/pyproject.toml +++ b/python/packages/codex/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI Codex SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260225" +version = "1.0.0a260410" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc2", - "codex-sdk>=0.1.0", + "agent-framework-core>=1.0.1,<2", + "codex-sdk>=0.1.0,<0.2", ] [tool.uv] @@ -61,6 +61,7 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" +include = ["agent_framework_codex"] exclude = ['tests'] [tool.mypy] @@ -85,9 +86,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_codex" -test = "pytest --cov=agent_framework_codex --cov-report=term-missing:skip-covered tests" +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_codex" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_codex --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/pyproject.toml b/python/pyproject.toml index a3876f34dd..3ec7f5c6b2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -74,6 +74,7 @@ agent-framework-azurefunctions = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-claude = { workspace = true } +agent-framework-codex = { workspace = true } agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } From 1912aa5487b3b93073947a59fc6a7e92fd33b6e6 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Fri, 10 Apr 2026 15:15:20 -0400 Subject: [PATCH 11/13] Python: Add agent-framework-codex to PACKAGE_STATUS.md as alpha Per python-package-management SKILL.md alpha checklist item #8. --- python/PACKAGE_STATUS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 661cebe53a..396a435984 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -24,6 +24,7 @@ Status is grouped into these buckets: | `agent-framework-bedrock` | `python/packages/bedrock` | `beta` | | `agent-framework-chatkit` | `python/packages/chatkit` | `beta` | | `agent-framework-claude` | `python/packages/claude` | `beta` | +| `agent-framework-codex` | `python/packages/codex` | `alpha` | | `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` | | `agent-framework-core` | `python/packages/core` | `released` | | `agent-framework-declarative` | `python/packages/declarative` | `beta` | From e2ef09e6bf84c0131556ac3f8fa4d49a987cbfae Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Tue, 14 Apr 2026 09:04:34 -0400 Subject: [PATCH 12/13] feat: add AgentMiddlewareLayer and explicit __init__ to CodexAgent - Update CodexAgent MRO to include AgentMiddlewareLayer before AgentTelemetryLayer (matching FoundryAgent pattern) - Add explicit __init__ exposing all parameters from all layers (RawCodexAgent, AgentTelemetryLayer, AgentMiddlewareLayer) - Add comprehensive docstring documenting all keyword arguments - Import AgentMiddlewareLayer from agent_framework - Add default_options property for telemetry layer compatibility --- .../codex/agent_framework_codex/_agent.py | 217 +++++++++++++++--- 1 file changed, 183 insertions(+), 34 deletions(-) diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py index 2d758b0a4a..9298de8591 100644 --- a/python/packages/codex/agent_framework_codex/_agent.py +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -5,10 +5,11 @@ import contextlib import logging import sys -from collections.abc import AsyncIterable, Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload +from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework import ( + AgentMiddlewareLayer, AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, @@ -19,6 +20,7 @@ Content, FunctionTool, Message, + MiddlewareTypes, ResponseStream, ToolTypes, load_settings, @@ -169,7 +171,11 @@ class CodexAgentOptions(TypedDict, total=False): class RawCodexAgent(BaseAgent, Generic[OptionsT]): - """OpenAI Codex Agent using Codex CLI. + """OpenAI Codex Agent using Codex CLI without telemetry layers. + + This is the core Codex agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`CodexAgent` which includes telemetry and + middleware support. Wraps the Codex SDK to provide agentic coding capabilities including tool use, session management, and streaming responses. @@ -239,11 +245,11 @@ def __init__( context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, - default_options: OptionsT | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize a CodexAgent instance. + """Initialize a RawCodexAgent instance. Args: instructions: System prompt for the agent. @@ -569,27 +575,53 @@ def _format_prompt(self, messages: list[Message] | None) -> str: return "" return "\n".join([msg.text or "" for msg in messages]) + @property + def default_options(self) -> dict[str, Any]: + """Expose options with ``instructions`` key. + + Maps ``system_prompt`` to ``instructions`` for compatibility with + :class:`AgentTelemetryLayer`, which reads the system prompt from + the ``instructions`` key. + """ + opts = dict(self._default_options) + system_prompt = opts.pop("system_prompt", None) + if system_prompt is not None: + opts["instructions"] = system_prompt + return opts + + def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + """Build AgentResponse and propagate structured_output as value. + + Args: + updates: The collected stream updates. + + Returns: + An AgentResponse with structured_output set as value if present. + """ + structured_output = getattr(self, "_structured_output", None) + return AgentResponse.from_updates(updates, value=structured_output) + @overload - def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, - stream: Literal[True], + stream: Literal[False] = ..., session: AgentSession | None = None, options: OptionsT | None = None, **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate]: ... + ) -> Awaitable[AgentResponse[Any]]: ... @overload - async def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, - stream: Literal[False] = ..., + stream: Literal[True], session: AgentSession | None = None, options: OptionsT | None = None, **kwargs: Any, - ) -> AgentResponse[Any]: ... + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, @@ -598,8 +630,8 @@ def run( stream: bool = False, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: + **kwargs: Any, # type: ignore + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages. Args: @@ -611,39 +643,27 @@ def run( session: The conversation session. If session has service_session_id set, the agent will resume that session. options: Runtime options (model, permission_mode can be changed per-request). - kwargs: Additional keyword arguments. + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by CodexAgent. Returns: When stream=True: An ResponseStream for streaming updates. When stream=False: An Awaitable[AgentResponse] with the complete response. """ response = ResponseStream( - self._get_stream(messages, session=session, options=options, **kwargs), + self._get_stream(messages, session=session, options=options), finalizer=self._finalize_response, ) if stream: return response return response.get_final_response() - def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: - """Build AgentResponse and propagate structured_output as value. - - Args: - updates: The collected stream updates. - - Returns: - An AgentResponse with structured_output set as value if present. - """ - structured_output = getattr(self, "_structured_output", None) - return AgentResponse.from_updates(updates, value=structured_output) - async def _get_stream( self, messages: AgentRunInputs | None = None, *, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal streaming implementation.""" session = session or self.create_session() @@ -724,12 +744,141 @@ async def _get_stream( self._structured_output = structured_output -class CodexAgent(AgentTelemetryLayer, RawCodexAgent[OptionsT], Generic[OptionsT]): - """OpenAI Codex Agent with built-in OpenTelemetry instrumentation. +class CodexAgent(AgentMiddlewareLayer, AgentTelemetryLayer, RawCodexAgent[OptionsT], Generic[OptionsT]): + """OpenAI Codex Agent with middleware and OpenTelemetry instrumentation. + + This is the recommended agent class for most use cases. It includes + OpenTelemetry-based telemetry for observability and middleware support + for intercepting agent invocations. For a minimal implementation + without telemetry or middleware, use :class:`RawCodexAgent`. - Extends :class:`RawCodexAgent` with automatic telemetry spans via - :class:`AgentTelemetryLayer`. Use ``RawCodexAgent`` directly if you - need the agent without telemetry overhead. + Examples: + Basic usage with context manager: + + .. code-block:: python + + from agent_framework_codex import CodexAgent + + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("Hello!") + print(response.text) """ - pass + def __init__( + self, + instructions: str | None = None, + *, + client: CodexSDKClient | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a CodexAgent with middleware and telemetry. + + Args: + instructions: System prompt for the agent. + + Keyword Args: + client: Optional pre-configured CodexSDKClient instance. If not provided, + a new client will be created using the other parameters. + id: Unique identifier for the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers for the agent. + middleware: Optional agent-level middleware for intercepting invocations. + tools: Tools for the agent. Can be: + - Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob") + - Functions for custom tools + default_options: Default CodexAgentOptions including system_prompt, model, etc. + env_file_path: Path to .env file. + env_file_encoding: Encoding of .env file. + """ + super().__init__( + instructions=instructions, + client=client, + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + tools=tools, + default_options=default_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the Codex agent with middleware and telemetry enabled.""" + super_run = cast( + "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", + super().run, + ) + return super_run( + messages=messages, + stream=stream, + session=session, + middleware=middleware, + options=options, + tools=tools, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) From f5316157613a1e8f6e0e1bd5d9b941b76742ce4f Mon Sep 17 00:00:00 2001 From: LEDazzio01 Date: Mon, 20 Apr 2026 16:52:38 -0400 Subject: [PATCH 13/13] fix: migrate from codex-sdk to codex-sdk-python and rebase on main - Switch dependency from codex-sdk (cleanlab internal SDK, wrong package) to codex-sdk-python>=0.117.0 (OpenAI Codex CLI Python SDK) - Rewrite _agent.py to use new SDK API: - Codex + Thread instead of CodexSDKClient - Thread.run_streamed_events() instead of client.query()/receive_response() - ItemUpdatedEvent/AgentMessageItem instead of StreamEvent/AssistantMessage - TurnCompletedEvent instead of ResultMessage - Thread IDs for session management instead of session IDs - Rename BaseContextProvider -> ContextProvider (upstream API rename) - Update CodexAgentSettings: remove max_turns/max_budget_usd, replace permission_mode with approval_policy to match new SDK - Rewrite test suite for new API (36 tests passing) - Rebase on upstream/main (Apr 20) - All checks pass: tests, ruff lint, ruff format, mypy --- .../codex/agent_framework_codex/_agent.py | 501 ++++------- python/packages/codex/pyproject.toml | 3 +- .../packages/codex/tests/test_codex_agent.py | 776 +++++------------- python/uv.lock | 27 +- 4 files changed, 396 insertions(+), 911 deletions(-) diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py index 9298de8591..a498942158 100644 --- a/python/packages/codex/agent_framework_codex/_agent.py +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence @@ -16,9 +15,8 @@ AgentRunInputs, AgentSession, BaseAgent, - BaseContextProvider, Content, - FunctionTool, + ContextProvider, Message, MiddlewareTypes, ResponseStream, @@ -27,19 +25,25 @@ normalize_messages, normalize_tools, ) -from agent_framework.observability import AgentTelemetryLayer from agent_framework.exceptions import AgentException +from agent_framework.observability import AgentTelemetryLayer from codex_sdk import ( - AssistantMessage, - CodexSDKClient, - ResultMessage, - SdkMcpTool, - create_sdk_mcp_server, + Codex, + CodexOptions, + Thread, + ThreadOptions, ) -from codex_sdk import ( - CodexAgentOptions as SDKOptions, +from codex_sdk.events import ( + ItemUpdatedEvent, + ThreadErrorEvent, + TurnCompletedEvent, + TurnFailedEvent, +) +from codex_sdk.items import ( + AgentMessageItem, + ErrorItem, + ReasoningItem, ) -from codex_sdk.types import StreamEvent, TextBlock if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -52,25 +56,15 @@ if TYPE_CHECKING: from codex_sdk import ( - AgentDefinition, - CanUseTool, - HookMatcher, - McpServerConfig, - PermissionMode, - SandboxSettings, - SdkBeta, + ApprovalMode, + ModelReasoningEffort, + SandboxMode, ) logger = logging.getLogger("agent_framework.codex") -# Name of the in-process MCP server that hosts Agent Framework tools. -# FunctionTool instances are converted to SDK MCP tools and served -# through this server, as Codex CLI only supports tools via MCP. -TOOLS_MCP_SERVER_NAME = "_agent_framework_tools" - - class CodexAgentSettings(TypedDict, total=False): """Codex Agent settings. @@ -79,20 +73,16 @@ class CodexAgentSettings(TypedDict, total=False): 'CODEX_AGENT_'. Keys: - cli_path: The path to Codex CLI executable. + codex_path: The path to Codex CLI executable. model: The model to use (codex-mini-latest, gpt-5.1-codex). cwd: The working directory for Codex CLI. - permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions). - max_turns: Maximum number of conversation turns. - max_budget_usd: Maximum budget in USD. + approval_policy: Approval policy (default, full-auto, plan). """ - cli_path: str | None + codex_path: str | None model: str | None cwd: str | None - permission_mode: str | None - max_turns: int | None - max_budget_usd: float | None + approval_policy: str | None class CodexAgentOptions(TypedDict, total=False): @@ -101,7 +91,7 @@ class CodexAgentOptions(TypedDict, total=False): system_prompt: str """System prompt for the agent.""" - cli_path: str + codex_path: str """Path to Codex CLI executable. Default: auto-detected.""" cwd: str @@ -113,53 +103,20 @@ class CodexAgentOptions(TypedDict, total=False): model: str """Model to use ("codex-mini-latest", "gpt-5.1-codex"). Default: "codex-mini-latest".""" - fallback_model: str - """Fallback model if primary fails.""" - - max_thinking_tokens: int - """Maximum tokens for thinking blocks.""" + sandbox_mode: SandboxMode + """Sandbox mode for code execution.""" - allowed_tools: list[str] - """Allowlist of tools. If set, Codex can ONLY use tools in this list.""" + model_reasoning_effort: ModelReasoningEffort + """Model reasoning effort preset.""" - disallowed_tools: list[str] - """Blocklist of tools. Codex cannot use these tools.""" + approval_policy: ApprovalMode + """Approval policy for tool execution.""" - mcp_servers: dict[str, McpServerConfig] - """MCP server configurations for external tools.""" - - permission_mode: PermissionMode - """Permission handling mode ("default", "acceptEdits", "plan", "bypassPermissions").""" - - can_use_tool: CanUseTool - """Permission callback for tool use.""" - - max_turns: int - """Maximum conversation turns.""" - - max_budget_usd: float - """Budget limit in USD.""" - - hooks: dict[str, list[HookMatcher]] - """Pre/post tool hooks.""" - - add_dirs: list[str] + additional_directories: list[str] """Additional directories to add to context.""" - sandbox: SandboxSettings - """Sandbox configuration for execution isolation.""" - - agents: dict[str, AgentDefinition] - """Custom agent definitions.""" - - output_format: dict[str, Any] - """Structured output format (JSON schema).""" - - enable_file_checkpointing: bool - """Enable file checkpointing for rewind.""" - - betas: list[SdkBeta] - """Beta features to enable.""" + config_overrides: dict[str, Any] + """Additional configuration overrides passed to the Codex CLI.""" OptionsT = TypeVar( @@ -215,7 +172,7 @@ class RawCodexAgent(BaseAgent, Generic[OptionsT]): session = agent.create_session() await agent.run("Remember my name is Alice", session=session) response = await agent.run("What's my name?", session=session) - # Codex will remember "Alice" from the same session + # Codex will remember "Alice" from the same thread With Agent Framework tools: @@ -238,11 +195,11 @@ def __init__( self, instructions: str | None = None, *, - client: CodexSDKClient | None = None, + client: Codex | None = None, id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, default_options: OptionsT | MutableMapping[str, Any] | None = None, @@ -255,7 +212,7 @@ def __init__( instructions: System prompt for the agent. Keyword Args: - client: Optional pre-configured CodexSDKClient instance. If not provided, + client: Optional pre-configured Codex instance. If not provided, a new client will be created using the other parameters. id: Unique identifier for the agent. name: Name of the agent. @@ -287,24 +244,19 @@ def __init__( if instructions is not None: opts["system_prompt"] = instructions - cli_path = opts.pop("cli_path", None) + codex_path = opts.pop("codex_path", None) model = opts.pop("model", None) cwd = opts.pop("cwd", None) - permission_mode = opts.pop("permission_mode", None) - max_turns = opts.pop("max_turns", None) - max_budget_usd = opts.pop("max_budget_usd", None) - self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {} + approval_policy = opts.pop("approval_policy", None) # Load settings from environment and options self._settings = load_settings( CodexAgentSettings, env_prefix="CODEX_AGENT_", - cli_path=cli_path, + codex_path=codex_path, model=model, cwd=cwd, - permission_mode=permission_mode, - max_turns=max_turns, - max_budget_usd=max_budget_usd, + approval_policy=approval_policy, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) @@ -315,8 +267,8 @@ def __init__( self._normalize_tools(tools) self._default_options = opts - self._started = False - self._current_session_id: str | None = None + self._current_thread: Thread | None = None + self._current_thread_id: str | None = None def _normalize_tools( self, @@ -358,14 +310,18 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: async def start(self) -> None: """Start the Codex SDK client. - This method initializes the Codex SDK client and establishes a connection - to the Codex CLI. It is called automatically when using the agent - as an async context manager. + This method initializes the Codex client. It is called automatically + when using the agent as an async context manager. Raises: AgentException: If the client fails to start. """ - await self._ensure_session() + if self._client is None: + try: + self._client = self._create_codex_client() + self._owns_client = True + except Exception as ex: + raise AgentException(f"Failed to create Codex client: {ex}") from ex async def stop(self) -> None: """Stop the Codex SDK client and clean up resources. @@ -373,194 +329,99 @@ async def stop(self) -> None: Stops the client if owned by this agent. Called automatically when using the agent as an async context manager. """ - if self._client and self._owns_client: - with contextlib.suppress(Exception): - await self._client.disconnect() - - self._started = False - self._current_session_id = None - - async def _ensure_session(self, session_id: str | None = None) -> None: - """Ensure the client is connected for the specified session. + self._current_thread = None + self._current_thread_id = None + if self._owns_client: + self._client = None - If the requested session differs from the current one, recreates the client. - Treats None as a distinct session identity so that switching from a resumed - session back to a fresh session correctly creates a new client. + def _create_codex_client(self) -> Codex: + """Create a Codex client with configured options. - Args: - session_id: The session ID to use, or None for a new session. + Returns: + A configured Codex instance. """ - needs_new_client = ( - not self._started - or self._client is None - or session_id != self._current_session_id - ) - - if needs_new_client: - # Stop existing client if any - if self._client and self._owns_client: - with contextlib.suppress(Exception): - await self._client.disconnect() - self._started = False + codex_path = self._settings.get("codex_path") + env = self._default_options.get("env") - # Create new client with resume option if needed - opts = self._prepare_client_options(resume_session_id=session_id) - self._client = CodexSDKClient(options=opts) - self._owns_client = True - - try: - await self._client.connect() - self._started = True - self._current_session_id = session_id - except Exception as ex: - self._client = None - raise AgentException(f"Failed to start Codex SDK client: {ex}") from ex + codex_opts = CodexOptions( + codex_path_override=codex_path, + env=env, + ) - def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOptions: - """Prepare SDK options for client initialization. + return Codex(options=codex_opts) - Args: - resume_session_id: Optional session ID to resume. + def _prepare_thread_options(self) -> ThreadOptions: + """Prepare ThreadOptions from settings and default options. Returns: - SDKOptions instance configured for the client. + ThreadOptions instance configured for the thread. """ - opts: dict[str, Any] = {} - - # Set resume option if provided - if resume_session_id: - opts["resume"] = resume_session_id + thread_opts_kwargs: dict[str, Any] = {} # Apply settings from environment - if cli_path := self._settings.get("cli_path"): - opts["cli_path"] = cli_path if model := self._settings.get("model"): - opts["model"] = model + thread_opts_kwargs["model"] = model if cwd := self._settings.get("cwd"): - opts["cwd"] = cwd - if permission_mode := self._settings.get("permission_mode"): - opts["permission_mode"] = permission_mode - if max_turns := self._settings.get("max_turns"): - opts["max_turns"] = max_turns - if max_budget_usd := self._settings.get("max_budget_usd"): - opts["max_budget_usd"] = max_budget_usd - - # Apply default options - for key, value in self._default_options.items(): - if value is not None: - opts[key] = value - - # Add built-in tools (strings like "Read", "Write", "Bash") - if self._builtin_tools: - opts["tools"] = self._builtin_tools - - # Prepare custom tools (FunctionTool instances) - custom_tools_server, custom_tool_names = ( - self._prepare_tools(self._custom_tools) if self._custom_tools else (None, []) - ) - - # MCP servers - merge user-provided servers with custom tools server - mcp_servers = dict(self._mcp_servers) if self._mcp_servers else {} - if custom_tools_server: - mcp_servers[TOOLS_MCP_SERVER_NAME] = custom_tools_server - if mcp_servers: - opts["mcp_servers"] = mcp_servers - - # Add custom tools to allowed_tools so they can be executed - if custom_tool_names: - existing_allowed = opts.get("allowed_tools", []) - opts["allowed_tools"] = list(existing_allowed) + custom_tool_names - - # Always enable partial messages for streaming support - opts["include_partial_messages"] = True - - return SDKOptions(**opts) - - def _prepare_tools( - self, - tools: Sequence[ToolTypes], - ) -> tuple[Any, list[str]]: - """Convert Agent Framework tools to SDK MCP server. - - Args: - tools: List of Agent Framework tools. - - Returns: - Tuple of (MCP server config, list of allowed tool names). - """ - sdk_tools: list[SdkMcpTool[Any]] = [] - tool_names: list[str] = [] - - for tool in tools: - if isinstance(tool, FunctionTool): - sdk_tools.append(self._function_tool_to_sdk_mcp_tool(tool)) - # Codex SDK convention: MCP tools use format "mcp__{server}__{tool}" - tool_names.append(f"mcp__{TOOLS_MCP_SERVER_NAME}__{tool.name}") - else: - # Non-FunctionTool items (e.g., dict-based hosted tools) cannot be converted to SDK MCP tools - logger.debug(f"Unsupported tool type: {type(tool)}") + thread_opts_kwargs["working_directory"] = cwd + if approval_policy := self._settings.get("approval_policy"): + thread_opts_kwargs["approval_policy"] = approval_policy + + # Apply default options (those not consumed by settings) + for key in ("sandbox_mode", "model_reasoning_effort", "additional_directories", "config_overrides"): + if key in self._default_options and self._default_options[key] is not None: + thread_opts_kwargs[key] = self._default_options[key] + + # Pass system prompt via config_overrides if set + system_prompt = self._default_options.get("system_prompt") + if system_prompt: + overrides = dict(thread_opts_kwargs.get("config_overrides") or {}) + overrides["instructions"] = system_prompt + thread_opts_kwargs["config_overrides"] = overrides + + # Write a temporary instructions file if we have a system prompt + if system_prompt and "model_instructions_file" not in thread_opts_kwargs: + import os + import tempfile + + fd, path = tempfile.mkstemp(suffix=".md", prefix="codex_instructions_") + try: + os.write(fd, system_prompt.encode("utf-8")) + finally: + os.close(fd) + thread_opts_kwargs["model_instructions_file"] = path - if not sdk_tools: - return None, [] + return ThreadOptions(**thread_opts_kwargs) - return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names + def _get_or_create_thread(self, session_id: str | None = None) -> Thread: + """Get or create a thread for the given session. - def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool) -> SdkMcpTool[Any]: - """Convert a FunctionTool to an SDK MCP tool. + If session_id matches the current thread, reuse it. + Otherwise, create a new thread or resume an existing one. Args: - func_tool: The FunctionTool to convert. + session_id: The thread/session ID to resume, or None for a new thread. Returns: - An SdkMcpTool instance. + A Thread instance. """ + if self._client is None: + raise RuntimeError("Codex client not initialized. Call start() first.") - async def handler(args: dict[str, Any]) -> dict[str, Any]: - """Handler that invokes the FunctionTool.""" - try: - if func_tool.input_model: - args_instance = func_tool.input_model(**args) - result = await func_tool.invoke(arguments=args_instance) - else: - result = await func_tool.invoke(arguments=args) - return {"content": [{"type": "text", "text": str(result)}]} - except Exception as e: - return {"content": [{"type": "text", "text": f"Error: {e}"}]} - - # Get JSON schema from pydantic model - schema: dict[str, Any] = func_tool.input_model.model_json_schema() if func_tool.input_model else {} - input_schema: dict[str, Any] = { - "type": "object", - "properties": schema.get("properties", {}), - "required": schema.get("required", []), - } - # Preserve $defs for nested type references (Pydantic uses $defs for nested models) - if "$defs" in schema: - input_schema["$defs"] = schema["$defs"] - - return SdkMcpTool( - name=func_tool.name, - description=func_tool.description, - input_schema=input_schema, - handler=handler, - ) - - async def _apply_runtime_options(self, options: dict[str, Any] | None) -> None: - """Apply runtime options that can be changed dynamically. + # Reuse current thread if session matches + if self._current_thread is not None and session_id == self._current_thread_id: + return self._current_thread - The Codex SDK supports changing model and permission_mode after connection. + thread_opts = self._prepare_thread_options() - Args: - options: Runtime options to apply. - """ - if not options or not self._client: - return + if session_id: + thread = self._client.resume_thread(session_id, options=thread_opts) + else: + thread = self._client.start_thread(options=thread_opts) - if "model" in options: - await self._client.set_model(options["model"]) + self._current_thread = thread + self._current_thread_id = session_id - if "permission_mode" in options: - await self._client.set_permission_mode(options["permission_mode"]) + return thread def _format_prompt(self, messages: list[Message] | None) -> str: """Format messages into a prompt string. @@ -590,16 +451,15 @@ def default_options(self) -> dict[str, Any]: return opts def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: - """Build AgentResponse and propagate structured_output as value. + """Build AgentResponse from collected updates. Args: updates: The collected stream updates. Returns: - An AgentResponse with structured_output set as value if present. + An AgentResponse built from the updates. """ - structured_output = getattr(self, "_structured_output", None) - return AgentResponse.from_updates(updates, value=structured_output) + return AgentResponse.from_updates(updates) @overload def run( # type: ignore[override] @@ -641,8 +501,8 @@ def run( stream: If True, returns an async iterable of updates. If False (default), returns an awaitable AgentResponse. session: The conversation session. If session has service_session_id set, - the agent will resume that session. - options: Runtime options (model, permission_mode can be changed per-request). + the agent will resume that thread. + options: Runtime options (model can be changed per-request via config_overrides). kwargs: Additional keyword arguments for compatibility with the shared agent interface (e.g. compaction_strategy, tokenizer). Not used by CodexAgent. @@ -668,80 +528,49 @@ async def _get_stream( """Internal streaming implementation.""" session = session or self.create_session() - # Ensure we're connected to the right session - await self._ensure_session(session.service_session_id) + # Ensure client is initialized + if self._client is None: + await self.start() - if not self._client: - raise RuntimeError("Codex SDK client not initialized.") + # Get or create thread for this session + thread = self._get_or_create_thread(session.service_session_id) prompt = self._format_prompt(normalize_messages(messages)) - # Apply runtime options (model, permission_mode) - await self._apply_runtime_options(dict(options) if options else None) - - session_id: str | None = None - structured_output: Any = None - - await self._client.query(prompt) - async for message in self._client.receive_response(): - if isinstance(message, StreamEvent): - # Handle streaming events - extract text/thinking deltas - event = message.event - if event.get("type") == "content_block_delta": - delta = event.get("delta", {}) - delta_type = delta.get("type") - if delta_type == "text_delta": - text = delta.get("text", "") - if text: - yield AgentResponseUpdate( - role="assistant", - contents=[Content.from_text(text=text, raw_representation=message)], - raw_representation=message, - ) - elif delta_type == "thinking_delta": - thinking = delta.get("thinking", "") - if thinking: - yield AgentResponseUpdate( - role="assistant", - contents=[Content.from_text_reasoning(text=thinking, raw_representation=message)], - raw_representation=message, - ) - elif isinstance(message, AssistantMessage): - # Handle AssistantMessage - check for API errors - # Note: In streaming mode, the content was already yielded via StreamEvent, - # so we only check for errors here, not re-emit content. - if message.error: - # Map error types to descriptive messages - error_messages = { - "authentication_failed": "Authentication failed with Codex API", - "billing_error": "Billing error with Codex API", - "rate_limit": "Rate limit exceeded for Codex API", - "invalid_request": "Invalid request to Codex API", - "server_error": "Codex API server error", - "unknown": "Unknown error from Codex API", - } - error_msg = error_messages.get(message.error, f"Codex API error: {message.error}") - # Extract any error details from content blocks - if message.content: - for block in message.content: - if isinstance(block, TextBlock): - error_msg = f"{error_msg}: {block.text}" - break - raise AgentException(error_msg) - elif isinstance(message, ResultMessage): - # Check for errors in result message - if message.is_error: - error_msg = message.result or "Unknown error from Codex API" - raise AgentException(f"Codex API error: {error_msg}") - session_id = message.session_id - structured_output = message.structured_output - - # Update session with session ID - if session_id: - session.service_session_id = session_id - - # Store structured output for the finalizer - self._structured_output = structured_output + async for event in thread.run_streamed_events(prompt): + if isinstance(event, ItemUpdatedEvent): + item = event.item + if isinstance(item, AgentMessageItem): + # Yield text content from agent messages + if item.text: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text(text=item.text, raw_representation=event)], + raw_representation=event, + ) + elif isinstance(item, ReasoningItem): + # Yield reasoning/thinking content + if item.text: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text_reasoning(text=item.text, raw_representation=event)], + raw_representation=event, + ) + elif isinstance(item, ErrorItem): + raise AgentException(f"Codex API error: {item.message}") + + elif isinstance(event, TurnFailedEvent): + error = event.error + raise AgentException(f"Codex turn failed: {error}") + + elif isinstance(event, ThreadErrorEvent): + raise AgentException(f"Codex thread error: {event}") + + elif isinstance(event, TurnCompletedEvent): + # Turn completed — update session with thread ID + if thread.id: + session.service_session_id = thread.id + self._current_thread_id = thread.id class CodexAgent(AgentMiddlewareLayer, AgentTelemetryLayer, RawCodexAgent[OptionsT], Generic[OptionsT]): @@ -770,11 +599,11 @@ def __init__( self, instructions: str | None = None, *, - client: CodexSDKClient | None = None, + client: Codex | None = None, id: str | None = None, name: str | None = None, description: str | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, + context_providers: Sequence[ContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, default_options: OptionsT | MutableMapping[str, Any] | None = None, @@ -787,7 +616,7 @@ def __init__( instructions: System prompt for the agent. Keyword Args: - client: Optional pre-configured CodexSDKClient instance. If not provided, + client: Optional pre-configured Codex instance. If not provided, a new client will be created using the other parameters. id: Unique identifier for the agent. name: Name of the agent. diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml index 548d724e20..bb1219c74e 100644 --- a/python/packages/codex/pyproject.toml +++ b/python/packages/codex/pyproject.toml @@ -24,11 +24,10 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.1,<2", - "codex-sdk>=0.1.0,<0.2", + "codex-sdk-python>=0.117.0,<1", ] [tool.uv] -prerelease = "if-necessary-or-explicit" environments = [ "sys_platform == 'darwin'", "sys_platform == 'linux'", diff --git a/python/packages/codex/tests/test_codex_agent.py b/python/packages/codex/tests/test_codex_agent.py index 7ae7feba65..4bd9443ba8 100644 --- a/python/packages/codex/tests/test_codex_agent.py +++ b/python/packages/codex/tests/test_codex_agent.py @@ -1,14 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool from agent_framework._settings import load_settings from agent_framework_codex import CodexAgent, CodexAgentOptions, CodexAgentSettings -from agent_framework_codex._agent import TOOLS_MCP_SERVER_NAME # region Test CodexAgentSettings @@ -19,39 +18,31 @@ class TestCodexAgentSettings: def test_default_values(self) -> None: """Test default values are None.""" settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") - assert settings["cli_path"] is None + assert settings["codex_path"] is None assert settings["model"] is None assert settings["cwd"] is None - assert settings["permission_mode"] is None - assert settings["max_turns"] is None - assert settings["max_budget_usd"] is None + assert settings["approval_policy"] is None def test_explicit_values(self) -> None: """Test explicit values override defaults.""" settings = load_settings( CodexAgentSettings, env_prefix="CODEX_AGENT_", - cli_path="/usr/local/bin/codex", + codex_path="/usr/local/bin/codex", model="codex-mini-latest", cwd="/home/user/project", - permission_mode="default", - max_turns=10, - max_budget_usd=5.0, + approval_policy="full-auto", ) - assert settings["cli_path"] == "/usr/local/bin/codex" + assert settings["codex_path"] == "/usr/local/bin/codex" assert settings["model"] == "codex-mini-latest" assert settings["cwd"] == "/home/user/project" - assert settings["permission_mode"] == "default" - assert settings["max_turns"] == 10 - assert settings["max_budget_usd"] == 5.0 + assert settings["approval_policy"] == "full-auto" def test_env_variable_loading(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test loading from environment variables.""" monkeypatch.setenv("CODEX_AGENT_MODEL", "gpt-5.1-codex") - monkeypatch.setenv("CODEX_AGENT_MAX_TURNS", "20") settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") assert settings["model"] == "gpt-5.1-codex" - assert settings["max_turns"] == 20 # region Test CodexAgent Initialization @@ -90,13 +81,9 @@ def test_with_default_options(self) -> None: """Test agent with default options.""" options: CodexAgentOptions = { "model": "codex-mini-latest", - "permission_mode": "default", - "max_turns": 10, } agent = CodexAgent(default_options=options) assert agent._settings["model"] == "codex-mini-latest" # type: ignore[reportPrivateUsage] - assert agent._settings["permission_mode"] == "default" # type: ignore[reportPrivateUsage] - assert agent._settings["max_turns"] == 10 # type: ignore[reportPrivateUsage] def test_with_function_tool(self) -> None: """Test agent with function tool.""" @@ -143,7 +130,7 @@ def greet(name: str) -> str: class TestCodexAgentLifecycle: - """Tests for CodexAgent tool initialization.""" + """Tests for CodexAgent lifecycle management.""" def test_custom_tools_stored_from_constructor(self) -> None: """Test that custom tools from constructor are stored.""" @@ -182,129 +169,102 @@ def test_no_tools(self) -> None: # region Test CodexAgent Run +def _make_mock_thread(events: list[Any]) -> MagicMock: + """Create a mock Thread that yields given events via run_streamed_events.""" + + async def _stream_events(*args: Any, **kwargs: Any) -> Any: + for event in events: + yield event + + mock_thread = MagicMock() + mock_thread.run_streamed_events = _stream_events + mock_thread.id = "thread-123" + return mock_thread + + +def _make_mock_codex(mock_thread: MagicMock) -> MagicMock: + """Create a mock Codex client that returns the given thread.""" + mock_codex = MagicMock() + mock_codex.start_thread.return_value = mock_thread + mock_codex.resume_thread.return_value = mock_thread + return mock_codex + + class TestCodexAgentRun: """Tests for CodexAgent run method.""" - @staticmethod - async def _create_async_generator(items: list[Any]) -> Any: - """Helper to create async generator from list.""" - for item in items: - yield item - - def _create_mock_client(self, messages: list[Any]) -> MagicMock: - """Create a mock CodexSDKClient that yields given messages.""" - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client.disconnect = AsyncMock() - mock_client.query = AsyncMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) - return mock_client - async def test_run_with_string_message(self) -> None: - """Test run with string message.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent - - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "Hello!"}, - }, - uuid="event-1", - session_id="session-123", + """Test run with string message yields text from ItemUpdatedEvent.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Hello!"), ), - AssistantMessage( - content=[TextBlock(text="Hello!")], - model="codex-mini-latest", - ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="session-123", + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() response = await agent.run("Hello") assert response.text == "Hello!" - async def test_run_captures_session_id(self) -> None: - """Test that session ID is captured from ResultMessage.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent + async def test_run_captures_thread_id(self) -> None: + """Test that thread ID is captured from completed turn.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "Response"}, - }, - uuid="event-1", - session_id="test-session-id", - ), - AssistantMessage( - content=[TextBlock(text="Response")], - model="codex-mini-latest", + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Response"), ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="test-session-id", + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_thread.id = "test-thread-id" + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() session = agent.create_session() await agent.run("Hello", session=session) - assert session.service_session_id == "test-session-id" + assert session.service_session_id == "test-thread-id" - async def test_run_with_session(self) -> None: - """Test run with existing session.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent + async def test_run_with_existing_session(self) -> None: + """Test run with existing session resumes thread.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "Response"}, - }, - uuid="event-1", - session_id="session-123", - ), - AssistantMessage( - content=[TextBlock(text="Response")], - model="codex-mini-latest", + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Response"), ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="session-123", + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() session = agent.create_session() - session.service_session_id = "existing-session" + session.service_session_id = "existing-thread" await agent.run("Hello", session=session) + mock_codex.resume_thread.assert_called_once() # region Test CodexAgent Run Stream @@ -313,125 +273,109 @@ async def test_run_with_session(self) -> None: class TestCodexAgentRunStream: """Tests for CodexAgent streaming run method.""" - @staticmethod - async def _create_async_generator(items: list[Any]) -> Any: - """Helper to create async generator from list.""" - for item in items: - yield item - - def _create_mock_client(self, messages: list[Any]) -> MagicMock: - """Create a mock CodexSDKClient that yields given messages.""" - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client.disconnect = AsyncMock() - mock_client.query = AsyncMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) - return mock_client - async def test_run_stream_yields_updates(self) -> None: """Test run(stream=True) yields AgentResponseUpdate objects.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "Streaming "}, - }, - uuid="event-1", - session_id="stream-session", + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Streaming "), ), - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "response"}, - }, - uuid="event-2", - session_id="stream-session", + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-2", type="agent_message", text="response"), ), - AssistantMessage( - content=[TextBlock(text="Streaming response")], - model="codex-mini-latest", - ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="stream-session", + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() updates: list[AgentResponseUpdate] = [] async for update in agent.run("Hello", stream=True): updates.append(update) - # StreamEvent yields text deltas (2 events) assert len(updates) == 2 assert updates[0].role == "assistant" assert updates[0].text == "Streaming " assert updates[1].text == "response" - async def test_run_stream_raises_on_assistant_message_error(self) -> None: - """Test run raises AgentException when AssistantMessage has an error.""" - from agent_framework.exceptions import AgentException - from codex_sdk import AssistantMessage, ResultMessage, TextBlock + async def test_run_stream_yields_reasoning(self) -> None: + """Test run(stream=True) yields reasoning updates.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem, ReasoningItem - messages = [ - AssistantMessage( - content=[TextBlock(text="Error details from API")], - model="codex-mini-latest", - error="invalid_request", + events = [ + ItemUpdatedEvent( + type="item.updated", + item=ReasoningItem(id="reason-1", type="reasoning", text="Let me think..."), + ), + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Hello!"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="error-session", + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + assert len(updates) == 2 + + async def test_run_stream_raises_on_error_item(self) -> None: + """Test run raises AgentException when ErrorItem is received.""" + from agent_framework.exceptions import AgentException + from codex_sdk.events import ItemUpdatedEvent + from codex_sdk.items import ErrorItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=ErrorItem(id="err-1", type="error", message="API rate limit exceeded"), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() with pytest.raises(AgentException) as exc_info: async for _ in agent.run("Hello", stream=True): pass - assert "Invalid request to Codex API" in str(exc_info.value) - assert "Error details from API" in str(exc_info.value) + assert "API rate limit exceeded" in str(exc_info.value) - async def test_run_stream_raises_on_result_message_error(self) -> None: - """Test run raises AgentException when ResultMessage.is_error is True.""" + async def test_run_stream_raises_on_turn_failed(self) -> None: + """Test run raises AgentException when TurnFailedEvent is received.""" from agent_framework.exceptions import AgentException - from codex_sdk import ResultMessage + from codex_sdk.events import ThreadError, TurnFailedEvent - messages = [ - ResultMessage( - subtype="error", - duration_ms=100, - duration_api_ms=50, - is_error=True, - num_turns=0, - session_id="error-session", - result="Model 'codex-mini-latest' not found", + events = [ + TurnFailedEvent( + type="turn.failed", + error=ThreadError(message="Model not found"), ), ] - mock_client = self._create_mock_client(messages) + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() with pytest.raises(AgentException) as exc_info: async for _ in agent.run("Hello", stream=True): pass - assert "Model 'codex-mini-latest' not found" in str(exc_info.value) + assert "turn failed" in str(exc_info.value).lower() # region Test CodexAgent Session Management @@ -453,209 +397,51 @@ def test_create_session_with_service_session_id(self) -> None: session = agent.create_session(session_id="existing-session-123") assert isinstance(session, AgentSession) - async def test_ensure_session_creates_client(self) -> None: - """Test _ensure_session creates client when not started.""" - with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client_class.return_value = mock_client - - agent = CodexAgent() - await agent._ensure_session(None) # type: ignore[reportPrivateUsage] - - assert agent._started # type: ignore[reportPrivateUsage] - mock_client.connect.assert_called_once() - - async def test_ensure_session_recreates_for_different_session(self) -> None: - """Test _ensure_session recreates client for different session ID.""" - with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: - mock_client1 = MagicMock() - mock_client1.connect = AsyncMock() - mock_client1.disconnect = AsyncMock() - - mock_client2 = MagicMock() - mock_client2.connect = AsyncMock() - - mock_client_class.side_effect = [mock_client1, mock_client2] - - agent = CodexAgent() - - # First session - await agent._ensure_session(None) # type: ignore[reportPrivateUsage] - assert agent._started # type: ignore[reportPrivateUsage] - - # Different session should recreate client - await agent._ensure_session("new-session-id") # type: ignore[reportPrivateUsage] - assert agent._current_session_id == "new-session-id" # type: ignore[reportPrivateUsage] - mock_client1.disconnect.assert_called_once() - - async def test_ensure_session_reuses_for_same_session(self) -> None: - """Test _ensure_session reuses client for same session ID.""" - with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client_class.return_value = mock_client - - agent = CodexAgent() - - # First call - await agent._ensure_session("session-123") # type: ignore[reportPrivateUsage] - - # Same session should not recreate - await agent._ensure_session("session-123") # type: ignore[reportPrivateUsage] - - # Only called once - assert mock_client_class.call_count == 1 - - async def test_ensure_session_recreates_when_resumed_then_fresh(self) -> None: - """Test _ensure_session creates a new client when switching from a resumed session to a fresh one (None). - - Regression test: previously, _ensure_session only recreated the client when - (session_id and session_id != self._current_session_id), so a transition from - a named session back to None would silently reuse the old client. - """ - with patch("agent_framework_codex._agent.CodexSDKClient") as mock_client_class: - mock_client1 = MagicMock() - mock_client1.connect = AsyncMock() - mock_client1.disconnect = AsyncMock() - - mock_client2 = MagicMock() - mock_client2.connect = AsyncMock() - - mock_client_class.side_effect = [mock_client1, mock_client2] - - agent = CodexAgent() - - # Start with a resumed session - await agent._ensure_session("resumed-session-id") # type: ignore[reportPrivateUsage] - assert agent._current_session_id == "resumed-session-id" # type: ignore[reportPrivateUsage] - assert mock_client_class.call_count == 1 - - # Switch to a fresh session (None) — must create a new client - await agent._ensure_session(None) # type: ignore[reportPrivateUsage] - assert agent._current_session_id is None # type: ignore[reportPrivateUsage] - assert mock_client_class.call_count == 2 - mock_client1.disconnect.assert_called_once() - - -# region Test CodexAgent Tool Conversion - - -class TestCodexAgentToolConversion: - """Tests for CodexAgent tool conversion.""" - - def test_prepare_tools_creates_mcp_server(self) -> None: - """Test _prepare_tools creates MCP server for AF tools.""" - - @tool - def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - agent = CodexAgent(tools=[add]) - server, tool_names = agent._prepare_tools(agent._custom_tools) # type: ignore[reportPrivateUsage] - - assert server is not None - assert len(tool_names) == 1 - assert tool_names[0] == f"mcp__{TOOLS_MCP_SERVER_NAME}__add" - - def test_function_tool_to_sdk_mcp_tool(self) -> None: - """Test converting FunctionTool to SDK MCP tool.""" - - @tool - def greet(name: str) -> str: - """Greet someone.""" - return f"Hello, {name}!" - - agent = CodexAgent() - sdk_tool = agent._function_tool_to_sdk_mcp_tool(greet) # type: ignore[reportPrivateUsage] - - assert sdk_tool.name == "greet" - assert sdk_tool.description == "Greet someone." - assert sdk_tool.input_schema is not None - assert "properties" in sdk_tool.input_schema # type: ignore[operator] - - def test_function_tool_to_sdk_mcp_tool_preserves_defs_for_nested_types(self) -> None: - """Test that $defs is preserved for tools with nested Pydantic models.""" - from pydantic import BaseModel - - class Address(BaseModel): - street: str - city: str - - class Person(BaseModel): - name: str - address: Address - - @tool - def create_person(person: Person) -> str: - """Create a person with address.""" - return f"{person.name} lives at {person.address.street}, {person.address.city}" + def test_get_or_create_thread_starts_new_thread(self) -> None: + """Test _get_or_create_thread starts new thread when no session.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.start_thread.return_value = mock_thread agent = CodexAgent() - sdk_tool = agent._function_tool_to_sdk_mcp_tool(create_person) # type: ignore[reportPrivateUsage] + agent._client = mock_codex # type: ignore[reportPrivateUsage] - # Verify $defs is preserved in the schema - assert sdk_tool.input_schema is not None - assert "$defs" in sdk_tool.input_schema # type: ignore[operator] - assert "Address" in sdk_tool.input_schema["$defs"] # type: ignore[index] - # Verify the nested reference exists in properties - assert "person" in sdk_tool.input_schema["properties"] # type: ignore[index] + thread = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] + mock_codex.start_thread.assert_called_once() + assert thread is mock_thread - async def test_tool_handler_success(self) -> None: - """Test tool handler executes successfully.""" - - @tool - def greet(name: str) -> str: - """Greet someone.""" - return f"Hello, {name}!" + def test_get_or_create_thread_resumes_existing(self) -> None: + """Test _get_or_create_thread resumes thread when session ID provided.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.resume_thread.return_value = mock_thread agent = CodexAgent() - sdk_tool = agent._function_tool_to_sdk_mcp_tool(greet) # type: ignore[reportPrivateUsage] + agent._client = mock_codex # type: ignore[reportPrivateUsage] - result = await sdk_tool.handler({"name": "World"}) - assert result["content"][0]["text"] == "Hello, World!" + thread = agent._get_or_create_thread("existing-thread-123") # type: ignore[reportPrivateUsage] + # Verify resume_thread was called with the correct thread ID + args = mock_codex.resume_thread.call_args + assert args[0][0] == "existing-thread-123" + assert thread is mock_thread - async def test_tool_handler_error(self) -> None: - """Test tool handler handles errors.""" - - @tool - def failing_tool() -> str: - """A tool that fails.""" - raise ValueError("Something went wrong") + def test_get_or_create_thread_reuses_for_same_session(self) -> None: + """Test _get_or_create_thread reuses thread for same session.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.start_thread.return_value = mock_thread agent = CodexAgent() - sdk_tool = agent._function_tool_to_sdk_mcp_tool(failing_tool) # type: ignore[reportPrivateUsage] - - result = await sdk_tool.handler({}) - assert "Error:" in result["content"][0]["text"] - assert "Something went wrong" in result["content"][0]["text"] - - -# region Test CodexAgent Permissions - + agent._client = mock_codex # type: ignore[reportPrivateUsage] -class TestCodexAgentPermissions: - """Tests for CodexAgent permission handling.""" + # First call + thread1 = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] - def test_default_permission_mode(self) -> None: - """Test default permission mode.""" - agent = CodexAgent() - assert agent._settings["permission_mode"] is None # type: ignore[reportPrivateUsage] + # Same session should reuse + thread2 = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] - def test_permission_mode_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test permission mode from environment settings.""" - monkeypatch.setenv("CODEX_AGENT_PERMISSION_MODE", "acceptEdits") - settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") - assert settings["permission_mode"] == "acceptEdits" - - def test_permission_mode_in_options(self) -> None: - """Test permission mode in options.""" - options: CodexAgentOptions = { - "permission_mode": "bypassPermissions", - } - agent = CodexAgent(default_options=options) - assert agent._settings["permission_mode"] == "bypassPermissions" # type: ignore[reportPrivateUsage] + assert thread1 is thread2 + assert mock_codex.start_thread.call_count == 1 # region Test CodexAgent Error Handling @@ -664,23 +450,12 @@ def test_permission_mode_in_options(self) -> None: class TestCodexAgentErrorHandling: """Tests for CodexAgent error handling.""" - @staticmethod - async def _empty_gen() -> Any: - """Empty async generator.""" - if False: - yield - async def test_handles_empty_response(self) -> None: - """Test handling of empty response.""" - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client.disconnect = AsyncMock() - mock_client.query = AsyncMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - mock_client.receive_response = MagicMock(return_value=self._empty_gen()) - - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): + """Test handling of empty response (no events).""" + mock_thread = _make_mock_thread([]) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): agent = CodexAgent() response = await agent.run("Hello") assert response.messages == [] @@ -728,184 +503,41 @@ def test_format_multiple_messages(self) -> None: assert "How are you?" in result -# region Test Build Options - +# region Test Default Options Property -class TestPrepareClientOptions: - """Tests for _prepare_client_options method.""" - def test_prepare_client_options_with_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test preparing options with settings.""" - monkeypatch.setenv("CODEX_AGENT_MODEL", "gpt-5.1-codex") - monkeypatch.setenv("CODEX_AGENT_MAX_TURNS", "15") - - agent = CodexAgent() +class TestDefaultOptionsProperty: + """Tests for the default_options property.""" - with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: - mock_opts.return_value = MagicMock() - agent._prepare_client_options() # type: ignore[reportPrivateUsage] - call_kwargs = mock_opts.call_args[1] - assert call_kwargs.get("model") == "gpt-5.1-codex" - assert call_kwargs.get("max_turns") == 15 - - def test_prepare_client_options_with_instructions(self) -> None: - """Test building options with instructions parameter.""" + def test_default_options_maps_system_prompt_to_instructions(self) -> None: + """Test that default_options maps system_prompt to instructions.""" agent = CodexAgent(instructions="Be helpful") + opts = agent.default_options + assert "instructions" in opts + assert opts["instructions"] == "Be helpful" + assert "system_prompt" not in opts - with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: - mock_opts.return_value = MagicMock() - agent._prepare_client_options() # type: ignore[reportPrivateUsage] - call_kwargs = mock_opts.call_args[1] - assert call_kwargs.get("system_prompt") == "Be helpful" - - def test_prepare_client_options_includes_custom_tools(self) -> None: - """Test that _prepare_client_options includes custom tools MCP server.""" - - @tool - def greet(name: str) -> str: - """Greet someone.""" - return f"Hello, {name}!" - - agent = CodexAgent(tools=[greet]) - - with patch("agent_framework_codex._agent.SDKOptions") as mock_opts: - mock_opts.return_value = MagicMock() - agent._prepare_client_options() # type: ignore[reportPrivateUsage] - call_kwargs = mock_opts.call_args[1] - assert "mcp_servers" in call_kwargs - assert TOOLS_MCP_SERVER_NAME in call_kwargs["mcp_servers"] - - -class TestApplyRuntimeOptions: - """Tests for _apply_runtime_options method.""" - - async def test_apply_runtime_model(self) -> None: - """Test applying runtime model option.""" - mock_client = MagicMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - + def test_default_options_without_system_prompt(self) -> None: + """Test default_options without system_prompt.""" agent = CodexAgent() - agent._client = mock_client # type: ignore[reportPrivateUsage] - - await agent._apply_runtime_options({"model": "gpt-5.1-codex"}) # type: ignore[reportPrivateUsage] - mock_client.set_model.assert_called_once_with("gpt-5.1-codex") + opts = agent.default_options + assert "instructions" not in opts + assert "system_prompt" not in opts - async def test_apply_runtime_permission_mode(self) -> None: - """Test applying runtime permission_mode option.""" - mock_client = MagicMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - agent = CodexAgent() - agent._client = mock_client # type: ignore[reportPrivateUsage] +# region Test Approval Policy - await agent._apply_runtime_options({"permission_mode": "acceptEdits"}) # type: ignore[reportPrivateUsage] - mock_client.set_permission_mode.assert_called_once_with("acceptEdits") - async def test_apply_runtime_options_none(self) -> None: - """Test applying None options does nothing.""" - mock_client = MagicMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() +class TestCodexAgentApprovalPolicy: + """Tests for CodexAgent approval policy handling.""" + def test_default_approval_policy(self) -> None: + """Test default approval policy is None.""" agent = CodexAgent() - agent._client = mock_client # type: ignore[reportPrivateUsage] - - await agent._apply_runtime_options(None) # type: ignore[reportPrivateUsage] - mock_client.set_model.assert_not_called() - mock_client.set_permission_mode.assert_not_called() - - -# region Test CodexAgent Structured Output - - -class TestCodexAgentStructuredOutput: - """Tests for CodexAgent structured output propagation.""" - - @staticmethod - async def _create_async_generator(items: list[Any]) -> Any: - """Helper to create async generator from list.""" - for item in items: - yield item - - def _create_mock_client(self, messages: list[Any]) -> MagicMock: - """Create a mock CodexSDKClient that yields given messages.""" - mock_client = MagicMock() - mock_client.connect = AsyncMock() - mock_client.disconnect = AsyncMock() - mock_client.query = AsyncMock() - mock_client.set_model = AsyncMock() - mock_client.set_permission_mode = AsyncMock() - mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) - return mock_client - - async def test_structured_output_propagated_to_response(self) -> None: - """Test that structured_output from ResultMessage is propagated to response.value.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent + assert agent._settings["approval_policy"] is None # type: ignore[reportPrivateUsage] - structured_data = {"name": "Alice", "age": 30} - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": '{"name": "Alice", "age": 30}'}, - }, - uuid="event-1", - session_id="session-123", - ), - AssistantMessage( - content=[TextBlock(text='{"name": "Alice", "age": 30}')], - model="codex-mini-latest", - ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="session-123", - structured_output=structured_data, - ), - ] - mock_client = self._create_mock_client(messages) - - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): - agent = CodexAgent() - response = await agent.run("Return structured data") - assert response.value == structured_data - - async def test_structured_output_none_when_not_present(self) -> None: - """Test that response.value is None when structured_output is not present.""" - from codex_sdk import AssistantMessage, ResultMessage, TextBlock - from codex_sdk.types import StreamEvent - - messages = [ - StreamEvent( - event={ - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "Hello!"}, - }, - uuid="event-1", - session_id="session-123", - ), - AssistantMessage( - content=[TextBlock(text="Hello!")], - model="codex-mini-latest", - ), - ResultMessage( - subtype="success", - duration_ms=100, - duration_api_ms=50, - is_error=False, - num_turns=1, - session_id="session-123", - ), - ] - mock_client = self._create_mock_client(messages) - - with patch("agent_framework_codex._agent.CodexSDKClient", return_value=mock_client): - agent = CodexAgent() - response = await agent.run("Hello") - assert response.value is None + def test_approval_policy_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test approval policy from environment settings.""" + monkeypatch.setenv("CODEX_AGENT_APPROVAL_POLICY", "full-auto") + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["approval_policy"] == "full-auto" diff --git a/python/uv.lock b/python/uv.lock index 370fd7e46d..8f1f6952f4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -36,6 +36,7 @@ members = [ "agent-framework-bedrock", "agent-framework-chatkit", "agent-framework-claude", + "agent-framework-codex", "agent-framework-copilotstudio", "agent-framework-core", "agent-framework-declarative", @@ -303,6 +304,21 @@ requires-dist = [ { name = "claude-agent-sdk", specifier = ">=0.1.36,<0.1.49" }, ] +[[package]] +name = "agent-framework-codex" +version = "1.0.0a260410" +source = { editable = "packages/codex" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "codex-sdk-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "codex-sdk-python", specifier = ">=0.117.0,<1" }, +] + [[package]] name = "agent-framework-copilotstudio" version = "1.0.0b260409" @@ -543,7 +559,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=0.2.1,>=0.2.1" }, ] [[package]] @@ -1466,6 +1482,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" }, ] +[[package]] +name = "codex-sdk-python" +version = "0.117.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/2a/6bc2c6695eb4b551fed25ce1e96f8026dc1b99e16595b29a862857e748b3/codex_sdk_python-0.117.0.tar.gz", hash = "sha256:ecbf0f023cbdbe30eb4dc9660933e2df83dfcc8b8b380c37e859366831aef722", size = 71231, upload-time = "2026-03-30T01:42:24.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d6/88053336495eabace0178421df746c060ee1c7d2865bdb0796b37bd01bd3/codex_sdk_python-0.117.0-py3-none-any.whl", hash = "sha256:c25c7ace9d01d4d6e93a5ee6d8d75236175bbff6049c607b172e104937c52d27", size = 67399, upload-time = "2026-03-30T01:42:23.826Z" }, +] + [[package]] name = "colorama" version = "0.4.6"