mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
## Why Applications need to deliver content from other agents, tools, or services with tool-level authority, without treating it as user input or granting authorization. ## What changed - Export `ExternalMessage` for sync and async `run(...)` and `turn(...)`, accepting text or structured content with a tool name and optional namespace. Send it through `toolOutput` and require CLI 0.151.0 or newer. - Support starting a turn or joining an active regular turn while preserving external content as function output in history. Keep external messages separate from user-input lists and `steer(...)`. - Give turn handles independent subscriptions, replaying completed items and latest usage to joining handles. Release consumed transient events and clean up subscriptions on closure, failure, or cancellation. - Document the authority boundary and add sync and async examples. ## Testing Add coverage for wire representations, input validation, runtime compatibility, tool authority across resume, active-turn joins, and tool-output truncation. Add subscription tests for replay, concurrent consumers, early completion, cancellation, and cleanup. GitOrigin-RevId: 6106327085fd9c4bd11b71e20b3d8e74738b8bb5
359 lines
12 KiB
Python
359 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
import openai_codex.api as public_api_module
|
|
from openai_codex.api import (
|
|
ApprovalMode,
|
|
AsyncCodex,
|
|
Codex,
|
|
ExternalMessage,
|
|
Sandbox,
|
|
TextInput,
|
|
)
|
|
from openai_codex.client import _params_dict
|
|
from openai_codex.generated.v2_all import TurnCompletedNotification, TurnStartParams
|
|
from openai_codex.models import InitializeResponse, Notification
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _approval_settings(params: list[Any]) -> list[dict[str, object]]:
|
|
"""Return serialized approval settings from captured Pydantic params."""
|
|
return [
|
|
{
|
|
key: value
|
|
for key, value in param.model_dump(
|
|
by_alias=True,
|
|
exclude_none=True,
|
|
mode="json",
|
|
).items()
|
|
if key in {"approvalPolicy", "approvalsReviewer"}
|
|
}
|
|
for param in params
|
|
]
|
|
|
|
|
|
def test_codex_init_failure_closes_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
closed: list[bool] = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, config=None) -> None: # noqa: ANN001,ARG002
|
|
self._closed = False
|
|
|
|
def start(self) -> None:
|
|
return None
|
|
|
|
def initialize(self) -> InitializeResponse:
|
|
return InitializeResponse.model_validate({})
|
|
|
|
def close(self) -> None:
|
|
self._closed = True
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(public_api_module, "CodexClient", FakeClient)
|
|
|
|
with pytest.raises(RuntimeError, match="missing required metadata"):
|
|
Codex()
|
|
|
|
assert closed == [True]
|
|
|
|
|
|
def test_async_codex_init_failure_closes_client() -> None:
|
|
async def scenario() -> None:
|
|
codex = AsyncCodex()
|
|
close_calls = 0
|
|
|
|
async def fake_start() -> None:
|
|
return None
|
|
|
|
async def fake_initialize() -> InitializeResponse:
|
|
return InitializeResponse.model_validate({})
|
|
|
|
async def fake_close() -> None:
|
|
nonlocal close_calls
|
|
close_calls += 1
|
|
|
|
codex._client.start = fake_start # type: ignore[method-assign]
|
|
codex._client.initialize = fake_initialize # type: ignore[method-assign]
|
|
codex._client.close = fake_close # type: ignore[method-assign]
|
|
|
|
with pytest.raises(RuntimeError, match="missing required metadata"):
|
|
await codex.models()
|
|
|
|
assert close_calls == 1
|
|
assert codex._initialized is False
|
|
assert codex._init is None
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_async_codex_initializes_only_once_under_concurrency() -> None:
|
|
async def scenario() -> None:
|
|
codex = AsyncCodex()
|
|
start_calls = 0
|
|
initialize_calls = 0
|
|
ready = asyncio.Event()
|
|
|
|
async def fake_start() -> None:
|
|
nonlocal start_calls
|
|
start_calls += 1
|
|
|
|
async def fake_initialize() -> InitializeResponse:
|
|
nonlocal initialize_calls
|
|
initialize_calls += 1
|
|
ready.set()
|
|
await asyncio.sleep(0.02)
|
|
return InitializeResponse.model_validate(
|
|
{
|
|
"userAgent": "codex-cli/1.2.3",
|
|
"serverInfo": {"name": "codex-cli", "version": "1.2.3"},
|
|
}
|
|
)
|
|
|
|
async def fake_model_list(include_hidden: bool = False): # noqa: ANN202,ARG001
|
|
await ready.wait()
|
|
return object()
|
|
|
|
codex._client.start = fake_start # type: ignore[method-assign]
|
|
codex._client.initialize = fake_initialize # type: ignore[method-assign]
|
|
codex._client.model_list = fake_model_list # type: ignore[method-assign]
|
|
|
|
await asyncio.gather(codex.models(), codex.models())
|
|
|
|
assert start_calls == 1
|
|
assert initialize_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize("api_type", [Codex, AsyncCodex])
|
|
@pytest.mark.parametrize(
|
|
("options", "expected"),
|
|
[
|
|
({}, {}),
|
|
({"include_turns": None}, {}),
|
|
({"include_turns": True}, {"excludeTurns": False}),
|
|
({"include_turns": False}, {"excludeTurns": True}),
|
|
],
|
|
)
|
|
def test_include_turns_preserves_omission_and_inverts_explicit_values(
|
|
api_type, options, expected
|
|
) -> None:
|
|
async def scenario() -> None:
|
|
async_api = api_type is AsyncCodex
|
|
rpc = AsyncMock if async_api else Mock
|
|
thread_response = SimpleNamespace(thread=SimpleNamespace(id="thread-2"))
|
|
client = SimpleNamespace(
|
|
thread_resume=rpc(return_value=thread_response),
|
|
thread_fork=rpc(return_value=thread_response),
|
|
)
|
|
codex = api_type.__new__(api_type)
|
|
codex._client = client
|
|
codex._initialized = True
|
|
|
|
for method in ("thread_resume", "thread_fork"):
|
|
thread = getattr(codex, method)("thread-1", **options)
|
|
if async_api:
|
|
thread = await thread
|
|
assert thread.id == "thread-2"
|
|
assert _params_dict(getattr(client, method).call_args.args[1]) == {
|
|
"threadId": "thread-1",
|
|
**expected,
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize("api_type", [Codex, AsyncCodex])
|
|
@pytest.mark.parametrize("method", ["run", "turn"])
|
|
@pytest.mark.parametrize(
|
|
"content", [None, "External update", [{"type": "input_text", "text": "External update"}]]
|
|
)
|
|
def test_turn_inputs_and_options_reach_the_client(api_type, method, content) -> None:
|
|
"""User and external inputs preserve distinct wire representations for every entry point."""
|
|
|
|
async def scenario() -> None:
|
|
async_api = api_type is AsyncCodex
|
|
rpc = AsyncMock if async_api else Mock
|
|
completed = Notification(
|
|
method="turn/completed",
|
|
payload=TurnCompletedNotification.model_validate(
|
|
{
|
|
"threadId": "thread-1",
|
|
"turn": {"id": "turn-1", "items": [], "status": "completed"},
|
|
}
|
|
),
|
|
)
|
|
client = SimpleNamespace(
|
|
turn_start=rpc(return_value=SimpleNamespace(turn=SimpleNamespace(id="turn-1"))),
|
|
_subscribe_turn_notifications=Mock(
|
|
return_value=SimpleNamespace(next=Mock(return_value=completed), close=Mock())
|
|
),
|
|
)
|
|
codex = api_type.__new__(api_type)
|
|
codex._client = client
|
|
codex._initialized = True
|
|
thread = (
|
|
public_api_module.AsyncThread(codex, "thread-1")
|
|
if async_api
|
|
else public_api_module.Thread(client, "thread-1")
|
|
)
|
|
input = (
|
|
"Continue."
|
|
if content is None
|
|
else ExternalMessage(tool_name="notifications", namespace="slack", content=content)
|
|
)
|
|
turn = getattr(thread, method)(
|
|
input,
|
|
service_tier="priority",
|
|
turn_service_tier="default",
|
|
source="automation",
|
|
)
|
|
if async_api:
|
|
turn = await turn
|
|
assert turn.id == "turn-1"
|
|
expected_input = (
|
|
[{"type": "text", "text": "Continue.", "text_elements": []}] if content is None else []
|
|
)
|
|
expected_tool_output = (
|
|
{}
|
|
if content is None
|
|
else {
|
|
"toolOutput": {"name": "notifications", "namespace": "slack", "output": content},
|
|
}
|
|
)
|
|
assert _params_dict(client.turn_start.call_args.kwargs["params"]) == {
|
|
"threadId": "thread-1",
|
|
"input": expected_input,
|
|
"serviceTier": "priority",
|
|
"serviceTierForTurn": "default",
|
|
"turnTrigger": "automation",
|
|
**expected_tool_output,
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize("api_type", [Codex, AsyncCodex])
|
|
def test_external_messages_cannot_be_mixed_with_user_input_or_sent_as_user_steering(
|
|
api_type,
|
|
) -> None:
|
|
async def scenario() -> None:
|
|
async_api = api_type is AsyncCodex
|
|
rpc = AsyncMock if async_api else Mock
|
|
client = SimpleNamespace(
|
|
turn_start=rpc(), turn_steer=rpc(), _subscribe_turn_notifications=Mock()
|
|
)
|
|
codex = api_type.__new__(api_type)
|
|
codex._client = client
|
|
codex._initialized = True
|
|
thread = (
|
|
public_api_module.AsyncThread(codex, "thread-1")
|
|
if async_api
|
|
else public_api_module.Thread(client, "thread-1")
|
|
)
|
|
handle = (
|
|
public_api_module.AsyncTurnHandle(codex, "thread-1", "turn-1")
|
|
if async_api
|
|
else public_api_module.TurnHandle(client, "thread-1", "turn-1")
|
|
)
|
|
external = ExternalMessage(tool_name="notifications", content="Untrusted update")
|
|
for operation, input in (
|
|
(thread.turn, [TextInput("User request"), external]),
|
|
(handle.steer, external),
|
|
):
|
|
with pytest.raises(TypeError):
|
|
result = operation(input)
|
|
if async_api:
|
|
await result
|
|
with pytest.raises(ValueError, match="tool_name"):
|
|
result = thread.turn(ExternalMessage(tool_name=" ", content="Untrusted update"))
|
|
if async_api:
|
|
await result
|
|
client.turn_start.assert_not_called()
|
|
client.turn_steer.assert_not_called()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def _approval_mode_turn_params(approval_mode: ApprovalMode) -> TurnStartParams:
|
|
"""Build real generated turn params from one public approval mode."""
|
|
approval_policy, approvals_reviewer = public_api_module._approval_mode_settings(approval_mode)
|
|
return TurnStartParams(
|
|
thread_id="thread-1",
|
|
input=[],
|
|
approval_policy=approval_policy,
|
|
approvals_reviewer=approvals_reviewer,
|
|
)
|
|
|
|
|
|
def test_approval_modes_serialize_to_expected_start_params() -> None:
|
|
"""ApprovalMode should map to the app-server params sent for new work."""
|
|
assert {
|
|
mode.value: _approval_settings([_approval_mode_turn_params(mode)])[0]
|
|
for mode in ApprovalMode
|
|
} == {
|
|
"deny_all": {"approvalPolicy": "never"},
|
|
"auto_review": {
|
|
"approvalPolicy": "on-request",
|
|
"approvalsReviewer": "auto_review",
|
|
},
|
|
}
|
|
|
|
|
|
def test_unknown_approval_mode_is_rejected() -> None:
|
|
"""Invalid approval modes should fail before params are constructed."""
|
|
with pytest.raises(ValueError, match="deny_all, auto_review"):
|
|
public_api_module._approval_mode_settings("allow_all") # type: ignore[arg-type]
|
|
|
|
|
|
def test_sandbox_presets_serialize_for_threads_and_turns() -> None:
|
|
"""One public sandbox enum should map to both stable wire representations."""
|
|
assert {
|
|
sandbox.name: public_api_module._sandbox_mode(sandbox).value for sandbox in Sandbox
|
|
} == {
|
|
"read_only": "read-only",
|
|
"workspace_write": "workspace-write",
|
|
"full_access": "danger-full-access",
|
|
}
|
|
assert {
|
|
sandbox.name: public_api_module._sandbox_policy(sandbox).model_dump(
|
|
by_alias=True,
|
|
mode="json",
|
|
)
|
|
for sandbox in Sandbox
|
|
} == {
|
|
"read_only": {"networkAccess": False, "type": "readOnly"},
|
|
"workspace_write": {
|
|
"excludeSlashTmp": False,
|
|
"excludeTmpdirEnvVar": False,
|
|
"networkAccess": False,
|
|
"type": "workspaceWrite",
|
|
"writableRoots": [],
|
|
},
|
|
"full_access": {"type": "dangerFullAccess"},
|
|
}
|
|
|
|
|
|
def test_raw_sandbox_strings_are_rejected() -> None:
|
|
"""Callers should use the discoverable enum rather than memorizing values."""
|
|
with pytest.raises(ValueError, match="Sandbox\\.workspace_write"):
|
|
public_api_module._sandbox_mode("workspace") # type: ignore[arg-type]
|
|
|
|
|
|
def test_retry_examples_compare_status_with_enum() -> None:
|
|
for path in (
|
|
ROOT / "examples" / "10_error_handling_and_retry" / "sync.py",
|
|
ROOT / "examples" / "10_error_handling_and_retry" / "async.py",
|
|
):
|
|
source = path.read_text()
|
|
assert '== "failed"' not in source
|
|
assert "TurnStatus.failed" in source
|