Support plaintext agent messages (#27830)

## Why

Multi-agent v2 `send_message` deliveries already reach the receiving
model as typed `agent_message` items with encrypted content.
Child-completion notifications are generated by Codex itself, so their
content is plaintext and previously fell back to a serialized JSON
envelope inside an assistant message.

With plaintext `input_text` supported for `agent_message`, both delivery
paths can use the same model-visible type while preserving explicit
author and recipient metadata.

## What changed

- add plaintext `input_text` support to `AgentMessageInputContent` and
regenerate the affected app-server schemas
- preserve `InterAgentCommunication` as structured mailbox input instead
of converting it to assistant text
- record delivered communications as typed `agent_message` history items
- persist a dedicated rollout item so local delivery metadata such as
`trigger_turn` remains available without leaking into the Responses
request
- reconstruct typed agent messages on resume and preserve fork-turn
truncation behavior
- remove request-time assistant-content parsing
- preserve plaintext and encrypted inter-agent deliveries in stage-one
memory inputs
- normalize and link plaintext and encrypted agent messages in rollout
traces without treating inbound messages as child results
- cover the real MultiAgent V2 child-completion path end to end with
deterministic mailbox synchronization

## Verification

- `just test -p codex-core
plaintext_multi_agent_v2_completion_sends_agent_message`
- `just test -p codex-core input_queue_drains_mailbox_in_delivery_order
record_initial_history_reconstructs_typed_inter_agent_message
fork_turn_positions_use_inter_agent_delivery_metadata`
- `just test -p codex-memories-write
serializes_inter_agent_communications_for_memory`
- `just test -p codex-rollout-trace
agent_messages_preserve_routing_and_content
sub_agent_started_activity_creates_spawn_edge`
- `just test -p codex-rollout-trace
agent_result_edge_falls_back_to_child_thread_without_result_message`
- `just test -p codex-protocol -p codex-rollout -p
codex-app-server-protocol`
This commit is contained in:
jif
2026-06-12 13:50:04 -07:00
committed by GitHub
parent 3e2ee1da3f
commit 8f2d6416ce
44 changed files with 716 additions and 113 deletions
@@ -14,4 +14,4 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:first prompt
03:message/assistant:first answer
04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false}
04:agent_message
@@ -14,4 +14,4 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:first prompt
03:reasoning:summary=thinking:encrypted=true
04:message/assistant:{"author":"/root/worker","recipient":"/root","other_recipients":[],"content":"queued child update","trigger_turn":false}
04:agent_message
@@ -1102,6 +1102,114 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()> {
let server = start_mock_server().await;
let spawn_args = serde_json::to_string(&json!({
"message": "opaque-encrypted-message",
"task_name": "worker",
}))?;
mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
sse(vec![
ev_response_created("resp-parent-1"),
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
ev_completed("resp-parent-1"),
]),
)
.await;
let child_request = mount_response_once_match(
&server,
|req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""),
sse_response(sse(vec![
ev_response_created("resp-child-1"),
ev_assistant_message("msg-child-1", "child done"),
ev_completed("resp-child-1"),
]))
.set_delay(Duration::from_secs(1)),
)
.await;
mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "<subagent_notification>")
},
sse(vec![
ev_response_created("resp-parent-2"),
ev_assistant_message("msg-parent-2", "parent done"),
ev_completed("resp-parent-2"),
]),
)
.await;
let notification = "<subagent_notification>\n{\"agent_path\":\"/root/worker\",\"status\":{\"completed\":\"child done\"}}\n</subagent_notification>";
// If the child is still running when the parent turn starts, wait_agent blocks
// until mailbox delivery. The follow-up request must then contain that delivery.
mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, TURN_2_NO_WAIT_PROMPT)
&& !body_contains(req, "<subagent_notification>")
},
sse(vec![
ev_response_created("resp-parent-3"),
ev_function_call("wait-agent-call", "wait_agent", "{}"),
ev_completed("resp-parent-3"),
]),
)
.await;
let agent_request = mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, TURN_2_NO_WAIT_PROMPT)
&& body_contains(req, "<subagent_notification>")
},
sse(vec![
ev_response_created("resp-parent-4"),
ev_assistant_message("msg-parent-4", "done"),
ev_completed("resp-parent-4"),
]),
)
.await;
let test = test_codex()
.with_model("koffing")
.with_config(|config| {
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
})
.build(&server)
.await?;
test.submit_turn(TURN_1_PROMPT).await?;
let _ = wait_for_requests(&child_request).await?;
test.submit_turn(TURN_2_NO_WAIT_PROMPT).await?;
let request = wait_for_requests(&agent_request)
.await?
.pop()
.expect("agent message request");
assert_eq!(
request.inputs_of_type("agent_message"),
vec![json!({
"type": "agent_message",
"author": "/root/worker",
"recipient": "/root",
"content": [{
"type": "input_text",
"text": notification,
}],
})]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> {
skip_if_no_network!(Ok(()));