Expose thread-level multi-agent mode (#28792)

## Why

Once multi-agent mode can be selected per turn, clients also need to
choose the initial selection when creating a thread and observe that
selection through lifecycle and settings APIs.

The selected value is intentionally distinct from the effective
model-visible value: no client selection is represented as `null`, even
though an eligible multi-agent v2 turn derives `explicitRequestOnly` as
its effective default.

## What changed

- Add the optional experimental `thread/start.multiAgentMode` parameter
and pass it through thread creation.
- Preserve an omitted initial value as an unset selection rather than
eagerly storing `explicitRequestOnly`.
- Apply an explicit `thread/start` selection to the first turn through
the session configuration established at thread creation.
- Restore the latest persisted effective mode as the selected baseline
on cold resume when rollout history contains one.
- Inherit the optional selected mode from a loaded parent when creating
related runtime threads.
- Return the current selected `multiAgentMode` from `thread/start`,
`thread/resume`, `thread/fork`, and thread settings, using `null` when
no mode is selected.
- Keep lifecycle reporting independent from model capability and feature
eligibility; core turn construction remains responsible for calculating
and persisting the effective mode.

## Not covered

- Clearing an existing loaded-session selection back to unset through
`turn/start`; omitted or `null` currently retains the session's
selection.
- A TUI control, slash command, or `config.toml` preference.

## Verification

- `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol`
- `CARGO_INCREMENTAL=0 just test -p codex-app-server multi_agent_mode`

The focused app-server coverage verifies explicit `thread/start`
initialization, first-turn prompting, nullable reporting for an omitted
selection, and retention of selections that are not currently
runtime-eligible.

## Stack

Stacked on #28685. This PR contains only the thread initialization and
lifecycle/settings API layer.
This commit is contained in:
Shijie Rao
2026-06-19 10:50:44 +02:00
committed by GitHub
parent fc8c6b7384
commit 7abfcf220b
46 changed files with 620 additions and 36 deletions
+2
View File
@@ -19,6 +19,7 @@ use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ContentItem;
@@ -68,6 +69,7 @@ pub(crate) struct SpawnAgentOptions {
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) initial_multi_agent_mode: Option<MultiAgentMode>,
}
#[derive(Clone, Debug)]
@@ -142,6 +142,7 @@ async fn spawn_v2_subagent(
/*forked_from_thread_id*/ None,
Some(ThreadSource::Subagent),
/*metrics_service_name*/ None,
/*initial_multi_agent_mode*/ None,
/*inherited_environments*/ None,
/*inherited_exec_policy*/ None,
/*environments*/ None,
+6
View File
@@ -1,11 +1,13 @@
use super::residency::is_v2_resident_session_source;
use super::*;
use codex_protocol::config_types::MultiAgentMode;
const AGENT_NAMES: &str = include_str!("../agent_names.txt");
struct SpawnAgentThreadInheritance {
environments: Option<TurnEnvironmentSnapshot>,
exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
inherited_multi_agent_mode: Option<MultiAgentMode>,
}
fn default_agent_nickname_list() -> Vec<&'static str> {
@@ -237,6 +239,7 @@ impl AgentControl {
exec_policy: self
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
.await,
inherited_multi_agent_mode: options.initial_multi_agent_mode,
};
let (session_source, mut agent_metadata) = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
@@ -283,6 +286,7 @@ impl AgentControl {
/*forked_from_thread_id*/ None,
/*thread_source*/ Some(ThreadSource::Subagent),
/*metrics_service_name*/ None,
inheritance.inherited_multi_agent_mode,
inheritance.environments,
inheritance.exec_policy,
options.environments.clone(),
@@ -388,6 +392,7 @@ impl AgentControl {
let SpawnAgentThreadInheritance {
environments: inherited_environments,
exec_policy: inherited_exec_policy,
inherited_multi_agent_mode,
} = inheritance;
if options.fork_parent_spawn_call_id.is_none() {
return Err(CodexErr::Fatal(
@@ -513,6 +518,7 @@ impl AgentControl {
/*thread_source*/ Some(ThreadSource::Subagent),
/*parent_thread_id*/ Some(parent_thread_id),
/*forked_from_thread_id*/ Some(parent_thread_id),
inherited_multi_agent_mode,
inherited_environments,
inherited_exec_policy,
options.environments.clone(),
+106 -2
View File
@@ -14,6 +14,7 @@ use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
@@ -816,6 +817,45 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_history() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
.await
.expect("spawn child without parent history")
.thread_id;
let child_snapshot = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered")
.config_snapshot()
.await;
assert_eq!(
child_snapshot.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
}
#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
let harness = AgentControlHarness::new().await;
@@ -838,6 +878,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
})
.await
.expect("update parent multi-agent mode");
parent_thread
.inject_user_message_without_turn("parent seed context".to_string())
.await;
@@ -923,6 +972,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
@@ -935,6 +985,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert_eq!(
child_thread.config_snapshot().await.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
assert_ne!(child_thread_id, parent_thread_id);
let history = child_thread.codex.session.clone_history().await;
let expected_history = [
@@ -1242,6 +1296,15 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
})
.await
.expect("update parent multi-agent mode");
parent_thread
.inject_user_message_without_turn("old parent context".to_string())
@@ -1326,6 +1389,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
initial_multi_agent_mode: Some(MultiAgentMode::Proactive),
..Default::default()
},
)
@@ -1338,6 +1402,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert_eq!(
child_thread.config_snapshot().await.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let history = child_thread.codex.session.clone_history().await;
assert!(
@@ -2185,7 +2253,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() {
let (home, mut config) = test_config().await;
config
.features
@@ -2207,7 +2275,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
manager,
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let agent_path = AgentPath::from_string("/root/explorer".to_string())
.expect("test agent path should be valid");
@@ -2232,6 +2300,38 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let mut child_turn_context = child_thread
.codex
.session
.new_default_turn()
.await
.to_turn_context_item();
child_turn_context.multi_agent_mode = Some(MultiAgentMode::Proactive);
child_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::TurnContext(child_turn_context)])
.await;
child_thread
.codex
.session
.ensure_rollout_materialized()
.await;
child_thread
.codex
.session
.flush_rollout()
.await
.expect("flush child effective multi-agent mode");
parent_thread
.codex
.session
.update_settings(crate::session::SessionSettingsUpdate {
multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly),
..Default::default()
})
.await
.expect("change parent multi-agent mode before child resume");
let mut status_rx = harness
.control
.subscribe_status(child_thread_id)
@@ -2320,6 +2420,10 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
assert_eq!(resumed_agent_path, Some(agent_path));
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
assert_eq!(
resumed_snapshot.multi_agent_mode,
Some(MultiAgentMode::Proactive)
);
let _ = harness
.control