mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -789,6 +789,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
personality: None,
|
||||
multi_agent_mode: None,
|
||||
ephemeral: None,
|
||||
session_start_source: None,
|
||||
thread_source: None,
|
||||
|
||||
@@ -22,7 +22,10 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_core::test_support::all_model_presets;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
@@ -94,6 +97,112 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
(1..=2)
|
||||
.map(|index| {
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created(&format!("resp-{index}")),
|
||||
responses::ev_assistant_message(&format!("msg-{index}"), "done"),
|
||||
responses::ev_completed(&format!("resp-{index}")),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
/*auto_compact_limit*/ 200_000,
|
||||
/*requires_openai_auth*/ None,
|
||||
"mock_provider",
|
||||
"compact",
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let thread = start_thread(&mut mcp).await?.thread;
|
||||
|
||||
start_text_turn(&mut mcp, thread.id.clone()).await?;
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(response_mock.requests().len(), 1);
|
||||
|
||||
send_thread_settings_update(
|
||||
&mut mcp,
|
||||
ThreadSettingsUpdateParams {
|
||||
thread_id: thread.id.clone(),
|
||||
multi_agent_mode: Some(MultiAgentMode::Proactive),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response_mock.requests().len(),
|
||||
1,
|
||||
"settings-only update should not start a model request"
|
||||
);
|
||||
|
||||
let updated = read_thread_settings_updated(&mut mcp).await?;
|
||||
assert_eq!(updated.thread_id, thread.id);
|
||||
assert_eq!(
|
||||
updated.thread_settings.multi_agent_mode,
|
||||
Some(MultiAgentMode::Proactive)
|
||||
);
|
||||
|
||||
start_text_turn(&mut mcp, thread.id).await?;
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
let first_developer_texts = requests[0].message_input_texts("developer");
|
||||
let second_developer_texts = requests[1].message_input_texts("developer");
|
||||
assert_eq!(
|
||||
first_developer_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
second_developer_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
second_developer_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains("Proactive multi-agent delegation is active."))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
second_developer_texts
|
||||
.iter()
|
||||
.filter(|text| text
|
||||
.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_settings_update_cwd_retargets_default_environment() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
|
||||
@@ -76,6 +76,7 @@ use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use core_test_support::responses;
|
||||
@@ -1827,6 +1828,140 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once(&server, body).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
multi_agent_mode: Some(MultiAgentMode::Proactive),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
thread,
|
||||
multi_agent_mode,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive));
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(turn_resp)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let developer_texts = response_mock
|
||||
.single_request()
|
||||
.message_input_texts("developer");
|
||||
assert!(
|
||||
developer_texts.iter().any(|text| {
|
||||
text.contains(MULTI_AGENT_MODE_OPEN_TAG)
|
||||
&& text.contains("Proactive multi-agent delegation is active.")
|
||||
}),
|
||||
"expected proactive multi-agent mode instructions in developer input, got {developer_texts:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let cases = [
|
||||
(
|
||||
BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
),
|
||||
(
|
||||
BTreeMap::new(),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
),
|
||||
(
|
||||
BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (features, requested_multi_agent_mode, expected_multi_agent_mode) in cases {
|
||||
let server = responses::start_mock_server().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never", &features)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
multi_agent_mode: requested_multi_agent_mode,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let response = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
assert_eq!(response.multi_agent_mode, expected_multi_agent_mode);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user