Add untrusted external messages to the Python SDK (#44086)

## 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
This commit is contained in:
Ahmed Ibrahim
2026-09-09 06:54:18 +00:00
committed by copyberry
parent 8afccec87a
commit 1a4096e273
19 changed files with 1031 additions and 87 deletions
@@ -13,7 +13,9 @@ 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
@@ -171,8 +173,11 @@ def test_include_turns_preserves_omission_and_inverts_explicit_values(
@pytest.mark.parametrize("api_type", [Codex, AsyncCodex])
@pytest.mark.parametrize("method", ["run", "turn"])
def test_turn_inputs_and_options_reach_the_client(api_type, method) -> None:
"""Turn options reach the client through both sync and async entry points."""
@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
@@ -188,9 +193,9 @@ def test_turn_inputs_and_options_reach_the_client(api_type, method) -> None:
)
client = SimpleNamespace(
turn_start=rpc(return_value=SimpleNamespace(turn=SimpleNamespace(id="turn-1"))),
register_turn_notifications=Mock(),
unregister_turn_notifications=Mock(),
next_turn_notification=rpc(return_value=completed),
_subscribe_turn_notifications=Mock(
return_value=SimpleNamespace(next=Mock(return_value=completed), close=Mock())
),
)
codex = api_type.__new__(api_type)
codex._client = client
@@ -200,7 +205,11 @@ def test_turn_inputs_and_options_reach_the_client(api_type, method) -> None:
if async_api
else public_api_module.Thread(client, "thread-1")
)
input = "Continue."
input = (
"Continue."
if content is None
else ExternalMessage(tool_name="notifications", namespace="slack", content=content)
)
turn = getattr(thread, method)(
input,
service_tier="priority",
@@ -210,18 +219,70 @@ def test_turn_inputs_and_options_reach_the_client(api_type, method) -> None:
if async_api:
turn = await turn
assert turn.id == "turn-1"
expected_input = [{"type": "text", "text": "Continue.", "text_elements": []}]
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)