[codex] Add Ultra reasoning effort (#29899)

## Why

Ultra should be one user-facing reasoning selection for work that
benefits from both maximum reasoning and proactive multi-agent
delegation. Without it, clients must coordinate maximum reasoning with
the experimental `multiAgentMode` setting, even though the inference
backend still expects its existing `max` effort value.

This change makes reasoning effort the source of truth: clients select
`ultra`, core derives proactive multi-agent behavior when the turn is
eligible for multi-agent V2, and inference requests continue to use the
backend-compatible `max` value.

## What changed

- Add `ultra` as a first-class reasoning effort and preserve
model-catalog ordering when exposing it to clients.
- Convert `ultra` to `max` at the inference request boundary, including
Responses HTTP/WebSocket requests, startup prewarm, compaction, and
memory summarization.
- Derive effective multi-agent mode per turn from effective reasoning
effort:
  - eligible multi-agent V2 + `ultra` → `proactive`
  - eligible multi-agent V2 + any other effort → `explicitRequestOnly`
- V1 or otherwise ineligible sessions → no multi-agent mode instruction
- Keep the derived effective mode in turn context history so successive
turns can emit a developer-message update only when the effective mode
changes.
- Remove selected multi-agent mode from core session configuration, turn
construction, thread settings, resume/fork restoration, and subagent
spawn plumbing. Subagents inherit reasoning effort and derive their own
effective mode.
- Retain the experimental app-server `multiAgentMode` fields for wire
compatibility while marking them deprecated. Request values are accepted
but ignored; compatibility response fields report `explicitRequestOnly`.
- Display Ultra in the TUI using the order supplied by `model/list`.

## Validation

- `just test -p codex-core ultra_reasoning_uses_max_for_requests`
- `just test -p codex-tui model_reasoning_selection_popup`
This commit is contained in:
Shijie Rao
2026-06-24 20:13:52 -07:00
committed by GitHub
parent fa036d39aa
commit df1199fddb
41 changed files with 172 additions and 651 deletions
+5 -5
View File
@@ -137,17 +137,17 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/turns/list` — experimental; page through a stored threads turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/settings/update` — experimental; queue a partial update to a loaded threads next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `none`, `explicitRequestOnly`, or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/settings/update` — experimental; queue a partial update to a loaded threads next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; deprecated `multiAgentMode` is ignored, while Ultra reasoning effort enables proactive multi-agent behavior; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/memoryMode/set` — experimental; set a threads persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`.
@@ -168,7 +168,7 @@ Example with notification opt-out:
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
- `thread/rollback` — drop the last N turns from the agents in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `none`, `explicitRequestOnly`, or `proactive`; `none` keeps the tools available without injecting mode instructions, and omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations, and eligible multi-agent v2 turns use it directly.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior.
- `thread/inject_items` — append raw Responses API items to a loaded threads model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::config_types::MultiAgentMode;
pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);
@@ -639,7 +640,6 @@ pub(super) async fn handle_pending_thread_resume_request(
active_permission_profile,
workspace_roots,
reasoning_effort,
multi_agent_mode,
..
} = config_snapshot;
let instruction_sources = pending.instruction_sources;
@@ -662,7 +662,7 @@ pub(super) async fn handle_pending_thread_resume_request(
sandbox,
active_permission_profile,
reasoning_effort,
multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
initial_turns_page,
};
outgoing.send_response(request_id, response).await;
@@ -903,7 +903,7 @@ impl ThreadRequestProcessor {
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
multi_agent_mode,
multi_agent_mode: _multi_agent_mode,
ephemeral,
session_start_source,
thread_source,
@@ -957,7 +957,6 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation,
config,
typesafe_overrides,
multi_agent_mode,
dynamic_tools,
selected_capability_roots.unwrap_or_default(),
session_start_source,
@@ -1032,7 +1031,6 @@ impl ThreadRequestProcessor {
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
multi_agent_mode: Option<MultiAgentMode>,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
@@ -1156,7 +1154,6 @@ impl ThreadRequestProcessor {
thread_source,
dynamic_tools,
metrics_service_name: service_name,
multi_agent_mode,
parent_trace: request_trace,
environments,
thread_extension_init,
@@ -1262,7 +1259,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
};
let notif = thread_started_notification(thread);
listener_task_context
@@ -2856,7 +2853,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
initial_turns_page,
};
@@ -3576,7 +3573,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
multi_agent_mode: config_snapshot.multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
};
let notif = thread_started_notification(thread);
@@ -776,7 +776,6 @@ mod thread_processor_behavior_tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
session_source: SessionSource::Cli,
forked_from_thread_id: None,
parent_thread_id: None,
@@ -3,6 +3,7 @@ use super::*;
use chrono::DateTime;
#[cfg(test)]
use chrono::Utc;
use codex_protocol::config_types::MultiAgentMode;
#[cfg(test)]
pub(crate) async fn read_summary_from_rollout(
@@ -205,7 +206,7 @@ pub(crate) fn thread_settings_from_config_snapshot(
effort: config_snapshot.reasoning_effort.clone(),
summary: config_snapshot.reasoning_summary,
collaboration_mode: config_snapshot.collaboration_mode.clone(),
multi_agent_mode: config_snapshot.multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
personality: config_snapshot.personality,
}
}
@@ -226,7 +227,6 @@ pub(crate) fn thread_settings_from_core_snapshot(
reasoning_summary,
personality,
collaboration_mode,
multi_agent_mode,
} = snapshot;
let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
ThreadSettings {
@@ -243,7 +243,7 @@ pub(crate) fn thread_settings_from_core_snapshot(
effort: reasoning_effort,
summary: reasoning_summary,
collaboration_mode,
multi_agent_mode,
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
personality,
}
}
@@ -1,5 +1,4 @@
use super::*;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
@@ -118,7 +117,6 @@ struct ThreadSettingsBuildParams {
effort: Option<ReasoningEffort>,
summary: Option<ReasoningSummary>,
collaboration_mode: Option<CollaborationMode>,
multi_agent_mode: Option<MultiAgentMode>,
personality: Option<Personality>,
}
@@ -515,7 +513,6 @@ impl TurnRequestProcessor {
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
multi_agent_mode: params.multi_agent_mode,
personality: params.personality,
},
)
@@ -622,7 +619,6 @@ impl TurnRequestProcessor {
effort,
summary,
collaboration_mode,
multi_agent_mode,
personality,
} = params;
@@ -656,7 +652,6 @@ impl TurnRequestProcessor {
|| effort.is_some()
|| summary.is_some()
|| collaboration_mode.is_some()
|| multi_agent_mode.is_some()
|| personality.is_some();
let runtime_workspace_roots =
@@ -733,7 +728,6 @@ impl TurnRequestProcessor {
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
multi_agent_mode,
personality,
})
.await
@@ -757,7 +751,6 @@ impl TurnRequestProcessor {
summary,
service_tier,
collaboration_mode,
multi_agent_mode,
personality,
})
}
@@ -788,7 +781,6 @@ impl TurnRequestProcessor {
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
multi_agent_mode: params.multi_agent_mode,
personality: params.personality,
},
)
+3 -1
View File
@@ -10,6 +10,8 @@ use codex_core::CodexThread;
use codex_core::ThreadConfigSnapshot;
use codex_file_watcher::WatchRegistration;
use codex_protocol::ThreadId;
#[cfg(test)]
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_rollout::state_db::StateDbHandle;
@@ -241,7 +243,7 @@ mod tests {
developer_instructions: None,
},
},
multi_agent_mode: Default::default(),
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
personality: None,
}
}
@@ -22,10 +22,7 @@ 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;
@@ -97,109 +94,6 @@ 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)]),
/*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,
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;
@@ -1754,7 +1754,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
}
#[tokio::test]
async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
async fn turn_start_ignores_deprecated_multi_agent_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1817,18 +1817,19 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
.single_request()
.message_input_texts("developer");
assert!(developer_texts.iter().any(|text| {
text.contains("<multi_agent_mode>")
&& text.contains("Proactive multi-agent delegation is active.")
}));
assert!(!developer_texts.iter().any(|text| {
text.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")
}));
assert!(
!developer_texts
.iter()
.any(|text| text.contains("Proactive multi-agent delegation is active."))
);
Ok(())
}
#[tokio::test]
async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
async fn thread_start_ignores_deprecated_multi_agent_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1867,7 +1868,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
multi_agent_mode,
..
} = to_response::<ThreadStartResponse>(thread_resp)?;
assert_eq!(multi_agent_mode, MultiAgentMode::Proactive);
assert_eq!(multi_agent_mode, MultiAgentMode::ExplicitRequestOnly);
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
@@ -1895,71 +1896,20 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
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("Do not spawn sub-agents unless the user explicitly asks for sub-agents")
}));
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:?}"
!developer_texts
.iter()
.any(|text| text.contains("Proactive multi-agent delegation is active."))
);
Ok(())
}
#[tokio::test]
async fn thread_start_reports_multi_agent_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let cases = [
(
BTreeMap::from([(Feature::MultiAgentV2, true)]),
Some(MultiAgentMode::Proactive),
MultiAgentMode::Proactive,
),
(
BTreeMap::from([(Feature::MultiAgentV2, true)]),
Some(MultiAgentMode::None),
MultiAgentMode::None,
),
(
BTreeMap::new(),
Some(MultiAgentMode::Proactive),
MultiAgentMode::Proactive,
),
(
BTreeMap::from([(Feature::MultiAgentV2, true)]),
None,
MultiAgentMode::ExplicitRequestOnly,
),
];
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_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request_with_auto_env(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(()));