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:46:01 +00:00
committed by copyberry
parent 8afccec87a
commit 1a4096e273
19 changed files with 1031 additions and 87 deletions

View File

@@ -1,11 +1,179 @@
from __future__ import annotations
import asyncio
import base64
from concurrent.futures import ThreadPoolExecutor
from app_server_harness import AppServerHarness
from app_server_helpers import TINY_PNG_BYTES
from app_server_helpers import TINY_PNG_BYTES, streaming_response
from openai_codex import Codex, ImageInput, LocalImageInput, SkillInput, TextInput
from openai_codex import (
AsyncCodex,
Codex,
ExternalMessage,
ImageInput,
LocalImageInput,
SkillInput,
TextInput,
)
def _external_items(request) -> list[dict]:
"""Select model-visible external content without generated item identifiers."""
return [
{key: value for key, value in item.items() if key != "id"}
for item in request.input()
if item.get("type") == "function_call_output"
]
def test_external_message_preserves_tool_authority_through_resume(tmp_path) -> None:
content = "External update: deployment completed."
expected = {
"type": "function_call_output",
"name": "notifications",
"namespace": "slack",
"output": content,
}
with AppServerHarness(tmp_path) as harness:
harness.responses.enqueue_assistant_message("Update received", response_id="external")
harness.responses.enqueue_assistant_message("Still available", response_id="resumed")
with Codex(config=harness.app_server_config()) as codex:
thread = codex.thread_start()
result = thread.run(
ExternalMessage(tool_name="notifications", namespace="slack", content=content)
)
external_item = next(
item for item in result.items if item.root.type == "functionCallOutput"
)
with Codex(config=harness.app_server_config()) as codex:
resumed = codex.thread_resume(thread.id, include_turns=False)
history = resumed.read(include_turns=True)
assert external_item in history.thread.turns[0].items
resumed.run("Summarize the external update.")
requests = harness.responses.requests()
assert result.final_response == "Update received"
assert [_external_items(request) for request in requests] == [[expected], [expected]]
assert [
text
for request in requests
for role in ("user", "developer")
for text in request.message_input_texts(role)
if content in text
] == []
def test_external_message_joins_active_turn_with_tool_authority(tmp_path) -> None:
content = "External update while the agent is running."
with AppServerHarness(tmp_path) as harness:
harness.responses.enqueue_sse(
streaming_response("external-first", "msg-first", ["Working"]),
delay_between_events_s=0.2,
)
harness.responses.enqueue_assistant_message(
"Update processed", response_id="external-second"
)
with ThreadPoolExecutor(max_workers=2) as consumers:
with Codex(config=harness.app_server_config()) as codex:
thread = codex.thread_start()
original = thread.turn("Monitor deployment updates.")
original_result = consumers.submit(original.run)
harness.responses.wait_for_requests(1)
joined = thread.turn(ExternalMessage(tool_name="notifications", content=content))
result = consumers.submit(joined.run).result(timeout=15)
assert original_result.result(timeout=15) == result
assert codex._client._router._turn_states == {}
requests = harness.responses.requests()
assert result.usage is not None
assert (joined.id, result.final_response) == (original.id, "Update processed")
assert [_external_items(request) for request in requests] == [
[],
[
{
"type": "function_call_output",
"name": "notifications",
"output": content,
}
],
]
assert [request.message_input_texts("user")[-1] for request in requests] == [
"Monitor deployment updates.",
"Monitor deployment updates.",
]
def test_async_external_message_reaches_model_with_tool_authority(tmp_path) -> None:
async def scenario() -> None:
with AppServerHarness(tmp_path) as harness:
harness.responses.enqueue_assistant_message(
"Async update received", response_id="external-async"
)
async with AsyncCodex(config=harness.app_server_config()) as codex:
thread = await codex.thread_start()
result = await thread.run(
ExternalMessage(tool_name="notifications", content="External async update")
)
request = harness.responses.single_request()
assert result.final_response == "Async update received"
assert _external_items(request) == [
{
"type": "function_call_output",
"name": "notifications",
"output": "External async update",
}
]
assert "External async update" not in request.message_input_texts("user")
asyncio.run(scenario())
def test_async_external_message_allows_both_handles_to_consume(tmp_path) -> None:
async def scenario() -> None:
with AppServerHarness(tmp_path) as harness:
harness.responses.enqueue_sse(
streaming_response("async-first", "msg-first", ["Working"]),
delay_between_events_s=0.2,
)
harness.responses.enqueue_assistant_message(
"Update processed", response_id="async-second"
)
async with AsyncCodex(config=harness.app_server_config()) as codex:
thread = await codex.thread_start()
original = await thread.turn("Monitor deployment updates.")
original_result = asyncio.create_task(original.run())
await asyncio.to_thread(harness.responses.wait_for_requests, 1)
joined = await thread.turn(
ExternalMessage(tool_name="notifications", content="Update")
)
first, second = await asyncio.wait_for(
asyncio.gather(original_result, joined.run()), timeout=15
)
assert first == second
assert first.final_response == "Update processed"
assert first.usage is not None
assert codex._client._sync._router._turn_states == {}
asyncio.run(scenario())
def test_external_message_uses_core_tool_output_truncation(tmp_path) -> None:
content = "External observation. " * 500
with AppServerHarness(tmp_path) as harness:
harness.responses.enqueue_assistant_message(
"Context received", response_id="truncated-external"
)
with Codex(config=harness.app_server_config()) as codex:
thread = codex.thread_start(config={"tool_output_token_limit": 32})
thread.run(ExternalMessage(tool_name="notifications", content=content))
request = harness.responses.single_request()
[item] = _external_items(request)
assert (item["name"], item["type"]) == ("notifications", "function_call_output")
assert len(item["output"]) < len(content)
assert "truncated" in item["output"].lower()
def test_data_url_image_input_reaches_responses_api(

View File

@@ -84,6 +84,7 @@ def _initialized_client(
@pytest.mark.parametrize(
("method", "params"),
[
("turn/start", {"input": [], "toolOutput": {"name": "delegate", "output": "Investigate"}}),
("turn/start", {"input": [], "turnTrigger": "automation"}),
("turn/start", {"input": [], "serviceTierForTurn": "default"}),
("thread/resume", {"threadId": "thread-1", "excludeTurns": False}),
@@ -117,7 +118,7 @@ def test_new_options_accept_supported_runtime_metadata(
monkeypatch: pytest.MonkeyPatch, metadata: JsonObject
) -> None:
client, requests = _initialized_client(monkeypatch, metadata)
params = {"input": [], "turnTrigger": "automation"}
params = {"input": [], "toolOutput": {"name": "delegate", "output": "Investigate"}}
client.request("turn/start", params, response_model=InitializeResponse)
@@ -665,7 +666,7 @@ def test_turn_notification_router_clears_unregistered_turn_when_completed() -> N
)
)
assert client._router._pending_turn_notifications == {}
assert client._router._turn_states == {}
def test_turn_notification_router_routes_unknown_turn_notifications() -> None:

View File

@@ -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)

View File

@@ -14,6 +14,7 @@ from openai_codex import (
AsyncTurnHandle,
Codex,
CodexConfig,
ExternalMessage,
Sandbox,
Thread,
TurnHandle,
@@ -46,6 +47,7 @@ EXPECTED_ROOT_EXPORTS = [
"Input",
"InputItem",
"RunInput",
"ExternalMessage",
"TextInput",
"ImageInput",
"LocalImageInput",
@@ -187,7 +189,7 @@ def test_turn_input_methods_accept_string_shortcut() -> None:
assert {
fn: inspect.signature(fn).parameters["input"].annotation
for fn in (TurnHandle.steer, AsyncTurnHandle.steer)
} == dict.fromkeys((TurnHandle.steer, AsyncTurnHandle.steer), "RunInput")
} == dict.fromkeys((TurnHandle.steer, AsyncTurnHandle.steer), "Input | str")
def test_root_exports_approval_mode() -> None:
@@ -228,6 +230,7 @@ def test_curated_public_api_has_builtin_help_documentation() -> None:
"TurnHandle": TurnHandle,
"AsyncTurnHandle": AsyncTurnHandle,
"TurnResult": TurnResult,
"ExternalMessage": ExternalMessage,
"Sandbox": Sandbox,
"thread_start": Codex.thread_start,
"thread_resume": Codex.thread_resume,

View File

@@ -0,0 +1,305 @@
import asyncio
import gc
import threading
from concurrent.futures import ThreadPoolExecutor
from itertools import chain
import pytest
from openai_codex import AsyncCodex
from openai_codex._run import _collect_turn_result
from openai_codex.api import AsyncTurnHandle, TurnHandle
from openai_codex.async_client import AsyncCodexClient
from openai_codex.client import CodexClient
from openai_codex.errors import TransportClosedError
def turn_events(client, *, status="completed"):
scope = {"threadId": "thread-1", "turnId": "turn-1"}
usage = {
"inputTokens": 2,
"cachedInputTokens": 0,
"outputTokens": 3,
"reasoningOutputTokens": 0,
"totalTokens": 5,
}
return [
client._coerce_notification(
"item/completed",
{
**scope,
"completedAtMs": 1,
"item": {
"id": "message",
"type": "agentMessage",
"text": "done",
"phase": "final_answer",
},
},
),
client._coerce_notification(
"thread/tokenUsage/updated", {**scope, "tokenUsage": {"last": usage, "total": usage}}
),
client._coerce_notification(
"turn/completed",
{
"threadId": "thread-1",
"turn": {
"id": "turn-1",
"items": [],
"status": status,
"error": {"message": "model failed"} if status == "failed" else None,
},
},
),
]
def test_late_join_replays_items_already_consumed_by_original_handle():
client = CodexClient()
original = TurnHandle(client, "thread-1", "turn-1")
events = turn_events(client)
client._router.route_notification(events[0])
stream = original.stream()
first = next(stream)
client._router.route_notification(events[1])
usage = next(stream)
joined = TurnHandle(client, "thread-1", "turn-1")
for event in events[2:]:
client._router.route_notification(event)
first_result = _collect_turn_result(chain([first, usage], stream), turn_id="turn-1")
assert joined.run() == first_result
assert first_result.final_response == "done"
assert first_result.usage.last.total_tokens == 5
assert client._router._turn_states == {}
def test_consumed_deltas_are_released_while_turn_is_active():
client = CodexClient()
subscription = client._subscribe_turn_notifications("turn-1")
state = client._router._turn_states["turn-1"]
for index in range(1000):
event = client._coerce_notification(
"item/agentMessage/delta",
{
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "message",
"delta": str(index),
},
)
client._router.route_notification(event)
assert subscription.next() == event
assert state.events == {}
assert state.completed_items == {}
assert not state.completed
subscription.close()
def test_slow_subscriber_keeps_unread_deltas_until_it_consumes_them():
client = CodexClient()
fast = client._subscribe_turn_notifications("turn-1")
slow = client._subscribe_turn_notifications("turn-1")
event = client._coerce_notification(
"item/agentMessage/delta",
{"threadId": "thread-1", "turnId": "turn-1", "itemId": "message", "delta": "hello"},
)
client._router.route_notification(event)
assert fast.next() == event
assert slow.next() == event
assert client._router._turn_states["turn-1"].events == {}
fast.close()
slow.close()
def test_completion_before_turn_start_response_is_replayed(monkeypatch):
client = CodexClient()
def request_raw(method, params):
assert method == "turn/start"
for event in turn_events(client):
client._router.route_notification(event)
return {"turn": {"id": "turn-1", "status": "inProgress", "items": []}}
monkeypatch.setattr(client, "_request_raw", request_raw)
started = client.turn_start("thread-1", "hello")
handle = TurnHandle(client, "thread-1", started.turn.id)
assert handle.run().final_response == "done"
assert client._router._turn_states == {}
def test_pending_join_preserves_result_after_original_handle_finishes():
client = CodexClient()
original = TurnHandle(client, "thread-1", "turn-1")
with client._router.pending_turn("thread-1"):
for event in turn_events(client):
client._router.route_notification(event)
result = original.run()
client._router.prepare_turn("turn-1", "thread-1")
joined = TurnHandle(client, "thread-1", "turn-1")
assert joined.run() == result
assert client._router._turn_states == {}
def test_failed_start_releases_early_completed_state():
client = CodexClient()
with pytest.raises(ValueError, match="request failed"):
with client._router.pending_turn("thread-1"):
for event in turn_events(client):
client._router.route_notification(event)
raise ValueError("request failed")
assert client._router._turn_states == {}
assert client._router._pending_turn_requests == {}
def test_closing_one_stream_leaves_other_subscriber_intact():
client = CodexClient()
original = TurnHandle(client, "thread-1", "turn-1")
joined = TurnHandle(client, "thread-1", "turn-1")
events = turn_events(client)
client._router.route_notification(events[0])
stream = original.stream()
next(stream)
stream.close()
for event in events[1:]:
client._router.route_notification(event)
assert joined.run().final_response == "done"
assert client._router._turn_states == {}
@pytest.mark.parametrize("failure", ["transport", "model"])
def test_both_handles_observe_failure_and_release_state(failure):
client = CodexClient()
handles = [TurnHandle(client, "thread-1", "turn-1") for _ in range(2)]
if failure == "transport":
client._router.fail_all(TransportClosedError("transport failed"))
else:
for event in turn_events(client, status="failed"):
client._router.route_notification(event)
for handle in handles:
with pytest.raises((TransportClosedError, RuntimeError), match=f"{failure} failed"):
handle.run()
assert client._router._turn_states == {}
def test_abandoned_handle_releases_completed_history():
client = CodexClient()
handle = TurnHandle(client, "thread-1", "turn-1")
for event in turn_events(client):
client._router.route_notification(event)
with client._router._lock:
del handle
gc.collect()
assert client._router._turn_states == {}
def test_cancelled_async_consumer_leaves_other_handle_intact():
async def scenario():
codex = AsyncCodex()
codex._initialized = True
client = codex._client._sync
original = AsyncTurnHandle(codex, "thread-1", "turn-1")
joined = AsyncTurnHandle(codex, "thread-1", "turn-1")
task = asyncio.create_task(original.run())
await asyncio.sleep(0) # Let the stream enter its wait before cancelling it.
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
for event in turn_events(client):
client._router.route_notification(event)
assert (await asyncio.wait_for(joined.run(), timeout=2)).final_response == "done"
assert client._router._turn_states == {}
asyncio.run(scenario())
def test_cancelled_turn_start_releases_result_after_response(monkeypatch):
async def scenario():
client = AsyncCodexClient()
entered = threading.Event()
respond = threading.Event()
released = threading.Event()
subscribe = client._sync._subscribe_turn_notifications
def request_raw(method, params):
entered.set()
assert respond.wait(timeout=2)
for event in turn_events(client._sync):
client._sync._router.route_notification(event)
return {"turn": {"id": "turn-1", "items": [], "status": "completed"}}
def observe_release(turn_id):
subscription = subscribe(turn_id)
close = subscription.close
def close_and_signal():
close()
released.set()
subscription.close = close_and_signal
return subscription
monkeypatch.setattr(client._sync, "_request_raw", request_raw)
monkeypatch.setattr(client._sync, "_subscribe_turn_notifications", observe_release)
task = asyncio.create_task(client.turn_start("thread-1", "hello"))
try:
assert await asyncio.to_thread(entered.wait, 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
respond.set()
assert await asyncio.to_thread(released.wait, 2)
assert client._sync._router._turn_states == {}
asyncio.run(scenario())
def test_cancelled_queued_start_does_not_send_a_request(monkeypatch):
with ThreadPoolExecutor(max_workers=1) as executor:
monkeypatch.setattr("openai_codex.async_client._TURN_START_EXECUTOR", executor)
release_worker = threading.Event()
worker_started = threading.Event()
request_sent = threading.Event()
def occupy_worker():
worker_started.set()
assert release_worker.wait(timeout=5)
occupied = executor.submit(occupy_worker)
assert worker_started.wait(timeout=5)
client = AsyncCodexClient()
monkeypatch.setattr(client._sync, "turn_start", lambda *args: request_sent.set())
async def scenario():
task = asyncio.create_task(client.turn_start("thread-1", "hello"))
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
try:
asyncio.run(scenario())
finally:
release_worker.set()
occupied.result(timeout=5)
executor.submit(lambda: None).result(timeout=5)
assert not request_sent.is_set()
def test_low_level_start_keeps_implicit_registration_and_explicit_unregister(monkeypatch):
client = CodexClient()
def request_raw(method, params):
for event in turn_events(client):
client._router.route_notification(event)
return {"turn": {"id": "turn-1", "status": "completed", "items": []}}
monkeypatch.setattr(client, "_request_raw", request_raw)
started = client.turn_start("thread-1", "hello")
assert client.next_turn_notification(started.turn.id) == turn_events(client)[0]
client.unregister_turn_notifications(started.turn.id)
with pytest.raises(RuntimeError, match="not registered"):
client.next_turn_notification(started.turn.id)
assert client._router._turn_states == {}