mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +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
295 lines
11 KiB
Python
295 lines
11 KiB
Python
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, streaming_response
|
|
|
|
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(
|
|
tmp_path,
|
|
) -> None:
|
|
"""Data URL image inputs should survive the SDK and app-server boundary."""
|
|
image_data_url = "data:image/png;base64," + base64.b64encode(TINY_PNG_BYTES).decode("ascii")
|
|
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message(
|
|
"data URL image received",
|
|
response_id="data-url-image",
|
|
)
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
result = codex.thread_start().run(
|
|
[
|
|
TextInput("Describe the data URL image."),
|
|
ImageInput(image_data_url),
|
|
]
|
|
)
|
|
request = harness.responses.single_request()
|
|
|
|
assert {
|
|
"final_response": result.final_response,
|
|
"contains_user_prompt": "Describe the data URL image."
|
|
in request.message_input_texts("user"),
|
|
"image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith(
|
|
"data:image/png;base64,"
|
|
),
|
|
} == {
|
|
"final_response": "data URL image received",
|
|
"contains_user_prompt": True,
|
|
"image_url_is_png_data_url": True,
|
|
}
|
|
|
|
|
|
def test_local_image_input_reaches_responses_api(
|
|
tmp_path,
|
|
) -> None:
|
|
"""Local image inputs should become data URLs after crossing the app-server."""
|
|
local_image = tmp_path / "local.png"
|
|
local_image.write_bytes(TINY_PNG_BYTES)
|
|
|
|
with AppServerHarness(tmp_path) as harness:
|
|
harness.responses.enqueue_assistant_message(
|
|
"local image received",
|
|
response_id="local-image",
|
|
)
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
result = codex.thread_start().run(
|
|
[
|
|
TextInput("Describe the local image."),
|
|
LocalImageInput(str(local_image)),
|
|
]
|
|
)
|
|
request = harness.responses.single_request()
|
|
|
|
assert {
|
|
"final_response": result.final_response,
|
|
"contains_user_prompt": "Describe the local image." in request.message_input_texts("user"),
|
|
"image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith(
|
|
"data:image/png;base64,"
|
|
),
|
|
} == {
|
|
"final_response": "local image received",
|
|
"contains_user_prompt": True,
|
|
"image_url_is_png_data_url": True,
|
|
}
|
|
|
|
|
|
def test_skill_input_injects_loaded_skill_body(tmp_path) -> None:
|
|
"""SkillInput should inject the selected loaded skill into model input."""
|
|
skill_body = "Use the word cobalt."
|
|
|
|
with AppServerHarness(tmp_path) as harness:
|
|
skill_file = harness.workspace / ".agents" / "skills" / "demo" / "SKILL.md"
|
|
skill_file.parent.mkdir(parents=True)
|
|
skill_file.write_text(f"---\nname: demo\ndescription: demo skill\n---\n\n{skill_body}\n")
|
|
skill_path = skill_file.resolve()
|
|
harness.responses.enqueue_assistant_message(
|
|
"skill received",
|
|
response_id="skill-input",
|
|
)
|
|
|
|
with Codex(config=harness.app_server_config()) as codex:
|
|
result = codex.thread_start().run(
|
|
[
|
|
TextInput("Use the selected skill."),
|
|
SkillInput("demo", str(skill_path)),
|
|
]
|
|
)
|
|
request = harness.responses.single_request()
|
|
|
|
skill_blocks = [
|
|
text for text in request.message_input_texts("user") if text.startswith("<skill>")
|
|
]
|
|
assert {
|
|
"final_response": result.final_response,
|
|
"skill_blocks": [
|
|
{
|
|
"has_name": "<name>demo</name>" in text,
|
|
"has_path": f"<path>{skill_path}</path>" in text,
|
|
"has_body": skill_body in text,
|
|
}
|
|
for text in skill_blocks
|
|
],
|
|
} == {
|
|
"final_response": "skill received",
|
|
"skill_blocks": [
|
|
{
|
|
"has_name": True,
|
|
"has_path": True,
|
|
"has_body": True,
|
|
}
|
|
],
|
|
}
|