diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index dac4829ab3b3..a9a1d90e74ec 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -224,6 +224,7 @@ fn sample_thread_start_response( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -288,6 +289,7 @@ fn sample_thread_resume_response_with_source( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 18bb8c2159e1..a14fe7bf2d50 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -314,6 +314,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -331,6 +332,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } @@ -349,6 +351,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8509a1c69d1c..6ba699dce7ec 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2611,6 +2611,14 @@ ], "type": "object" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index bebd194497f5..11efbb7e824b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 73e228845d45..de242b62b3c1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index fbcaee3ee8b9..67a0f5c5d955 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -108,6 +108,14 @@ ], "type": "string" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d4291f6dc1a4..eebb5da9602c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -194,6 +194,14 @@ "LegacyAppPathString": { "type": "string" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 726088c0d956..4ad961c22cab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts index bcfd0ad86ce3..b034ea80bb35 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -11,4 +11,4 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; +export type ThreadSettings = {cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 643afcc58d44..2f322aa5ff26 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2583,6 +2583,7 @@ mod tests { sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: None, }, }; @@ -2630,7 +2631,8 @@ mod tests { "type": "dangerFullAccess" }, "activePermissionProfile": null, - "reasoningEffort": null + "reasoningEffort": null, + "multiAgentMode": null } }), serde_json::to_value(&response)?, @@ -3545,6 +3547,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, }, }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 96cbb1ed23bc..0161cd4ef94a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -199,6 +199,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { sandbox: SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: Some(TurnsPage { data: Vec::new(), next_cursor: Some("cursor_next".to_string()), @@ -3701,6 +3702,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(resume.active_permission_profile, None); assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); + assert_eq!( + ( + start.multi_agent_mode, + resume.multi_agent_mode, + fork.multi_agent_mode, + ), + (None, None, None) + ); let foreign_source: LegacyAppPathString = serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source"); @@ -3805,6 +3814,27 @@ fn turn_start_params_round_trip_multi_agent_mode() { ); } +#[test] +fn thread_start_params_round_trip_multi_agent_mode() { + let params: ThreadStartParams = serde_json::from_value(json!({ + "multiAgentMode": "proactive" + })) + .expect("params should deserialize"); + + assert_eq!( + params.multi_agent_mode, + Some(codex_protocol::config_types::MultiAgentMode::Proactive) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("thread/start.multiAgentMode") + ); + assert_eq!( + serde_json::to_value(params).expect("params should serialize")["multiAgentMode"], + "proactive" + ); +} + #[test] fn thread_settings_update_params_preserve_explicit_null_service_tier() { let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 59adcbaa85be..3c527d62197e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -14,6 +14,7 @@ use codex_experimental_api_macros::ExperimentalApi; pub use codex_protocol::capabilities::CapabilityRootLocation; pub use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -93,6 +94,12 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, + /// Set the initial multi-agent mode for this thread. + /// Omitted leaves the thread without a selected mode. Eligible multi-agent + /// v2 turns still default to `explicitRequestOnly`. + #[experimental("thread/start.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, #[ts(optional = nullable)] pub ephemeral: Option, #[ts(optional = nullable)] @@ -179,6 +186,10 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/start.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, } impl ThreadStartResponse { @@ -239,6 +250,10 @@ pub struct ThreadSettingsUpdateParams { #[experimental("thread/settings/update.collaborationMode")] #[ts(optional = nullable)] pub collaboration_mode: Option, + /// Select the multi-agent mode for subsequent turns. + #[experimental("thread/settings/update.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, /// Override the personality for subsequent turns. #[ts(optional = nullable)] pub personality: Option, @@ -249,7 +264,7 @@ pub struct ThreadSettingsUpdateParams { #[ts(export_to = "v2/")] pub struct ThreadSettingsUpdateResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadSettings { @@ -264,6 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/settings.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, pub personality: Option, } @@ -400,6 +419,10 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/resume.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] @@ -555,6 +578,10 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/fork.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, } impl ThreadForkResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5914c3a858c4..f8ceb69d42bd 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 `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. 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/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; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. 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. 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/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. +- `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 selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. 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 thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `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 thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `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 thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `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/memoryMode/set` — experimental; set a thread’s 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 agent’s 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 `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns use the requested mode when `features.multi_agent_mode` is enabled and otherwise use explicit-request-only developer instructions. +- `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 `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions. - `thread/inject_items` — append raw Responses API items to a loaded thread’s 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"`. diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index fd9e93e1894c..21adf4613476 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -639,6 +639,7 @@ 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; @@ -661,6 +662,7 @@ pub(super) async fn handle_pending_thread_resume_request( sandbox, active_permission_profile, reasoning_effort, + multi_agent_mode, initial_turns_page, }; outgoing.send_response(request_id, response).await; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 1aa6e4eeac51..a3e4b49ce884 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2,6 +2,7 @@ use super::*; use crate::error_code::method_not_found; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_extension_api::ExtensionDataInit; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -902,6 +903,7 @@ impl ThreadRequestProcessor { mock_experimental_field: _mock_experimental_field, experimental_raw_events, personality, + multi_agent_mode, ephemeral, session_start_source, thread_source, @@ -955,6 +957,7 @@ impl ThreadRequestProcessor { supports_openai_form_elicitation, config, typesafe_overrides, + multi_agent_mode, dynamic_tools, selected_capability_roots.unwrap_or_default(), session_start_source, @@ -1029,6 +1032,7 @@ impl ThreadRequestProcessor { supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, + multi_agent_mode: Option, dynamic_tools: Option>, selected_capability_roots: Vec, session_start_source: Option, @@ -1152,6 +1156,7 @@ impl ThreadRequestProcessor { thread_source, dynamic_tools, metrics_service_name: service_name, + multi_agent_mode, parent_trace: request_trace, environments, thread_extension_init, @@ -1257,6 +1262,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); listener_task_context @@ -2787,6 +2793,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, initial_turns_page, }; @@ -3508,6 +3515,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 67d2d9eabcd2..bb2c6708cdc2 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -1,5 +1,4 @@ use super::*; - #[cfg(test)] use chrono::DateTime; #[cfg(test)] @@ -206,6 +205,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, personality: config_snapshot.personality, } } @@ -226,6 +226,7 @@ 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 { @@ -242,6 +243,7 @@ pub(crate) fn thread_settings_from_core_snapshot( effort: reasoning_effort, summary: reasoning_summary, collaboration_mode, + multi_agent_mode, personality, } } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index c6a739afcbe8..7b5cafe0aabe 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -729,7 +729,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, - multi_agent_mode: None, + multi_agent_mode: params.multi_agent_mode, personality: params.personality, }, ) diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 91de76b99cd8..e4e4e42adfe2 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -241,6 +241,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 491c0608507a..ef7ee6c1d127 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 29dbee26d17d..efdf13aae98f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -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; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 058674653a94..10d15999676a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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::(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::(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(())); diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 410c97cbd59a..2f8d035435ba 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -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, pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, + pub(crate) initial_multi_agent_mode: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index cf043971e269..f91a77364ae0 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -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, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index bc6dba1a48cf..3f746ef79041 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -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, exec_policy: Option>, + inherited_multi_agent_mode: Option, } 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(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index fdd9b08375fd..66b5b5c4cb5d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -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 diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index dab902ec3741..9f5b867f7512 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -122,6 +122,7 @@ pub(crate) async fn run_codex_thread_interactive( attestation_provider: parent_session.services.attestation_provider.clone(), external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)), inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), + initial_multi_agent_mode: None, })) .or_cancel(&cancel_token) .await??; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index f3becabc6e86..7cf877a72856 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -106,7 +106,7 @@ fn build_multi_agent_mode_update_item( &next.config.multi_agent_v2, &next.session_source, next.multi_agent_mode, - next.features.enabled(Feature::MultiAgentMode), + next.config.features.enabled(Feature::MultiAgentMode), ); let previous = previous?; if previous.multi_agent_mode == effective_multi_agent_mode { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 98aef2572440..2feee3e9f340 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -178,6 +178,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, collaboration_mode: snapshot.collaboration_mode, + multi_agent_mode: snapshot.multi_agent_mode, }, }) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8b7a15ff08de..c627fe537162 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -445,6 +445,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) attestation_provider: Option>, pub(crate) external_time_provider: Option>, pub(crate) inherited_multi_agent_version: Option, + pub(crate) initial_multi_agent_mode: Option, } pub(crate) fn resolve_multi_agent_version( @@ -529,6 +530,7 @@ impl Codex { attestation_provider, external_time_provider, inherited_multi_agent_version, + initial_multi_agent_mode, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -583,7 +585,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = conversation_history.get_multi_agent_mode(); + let multi_agent_mode = initial_multi_agent_mode; config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -3249,7 +3251,10 @@ impl Session { &turn_context.config.multi_agent_v2, &session_source, turn_context.multi_agent_mode, - turn_context.features.enabled(Feature::MultiAgentMode), + turn_context + .config + .features + .enabled(Feature::MultiAgentMode), ) { items.push(ContextualUserFragment::into( MultiAgentModeInstructions::new(multi_agent_mode), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5de331ee1fb1..7011e6d0f548 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -738,6 +738,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { attestation_provider: None, external_time_provider: None, inherited_multi_agent_version: None, + initial_multi_agent_mode: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 18d56cdfec34..30d7d28e56da 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -388,7 +388,7 @@ impl TurnContext { &self.config.multi_agent_v2, &self.session_source, self.multi_agent_mode, - self.features.enabled(Feature::MultiAgentMode), + self.config.features.enabled(Feature::MultiAgentMode), ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 814c8fe764d4..3a0e3747503c 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -36,6 +36,7 @@ use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::openai_models::ModelPreset; @@ -184,6 +185,7 @@ pub struct StartThreadOptions { pub thread_source: Option, pub dynamic_tools: Vec, pub metrics_service_name: Option, + pub multi_agent_mode: Option, pub parent_trace: Option, pub environments: Vec, pub thread_extension_init: ExtensionDataInit, @@ -607,6 +609,7 @@ impl ThreadManager { thread_source: None, dynamic_tools, metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: ExtensionDataInit::default(), @@ -646,6 +649,7 @@ impl ThreadManager { thread_source, options.dynamic_tools, options.metrics_service_name, + options.multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, options.parent_trace, @@ -730,6 +734,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -741,6 +746,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -773,6 +779,7 @@ impl ThreadManager { /*thread_source*/ None, Vec::new(), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -799,6 +806,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -810,6 +818,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*parent_trace*/ None, @@ -960,18 +969,25 @@ impl ThreadManager { ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. - let forked_from_thread_id = match &history { + let source_thread_id = match &history { InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; + let initial_multi_agent_mode = match source_thread_id { + Some(thread_id) => match self.get_thread(thread_id).await { + Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Err(_) => history.get_latest_effective_multi_agent_mode(), + }, + None => history.get_latest_effective_multi_agent_mode(), + }; let multi_agent_version = self .state .effective_multi_agent_version_for_spawn( &history, /*session_source*/ None, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, &config, ) .await; @@ -989,10 +1005,11 @@ impl ThreadManager { Arc::clone(&self.state.auth_manager), agent_control, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -1217,6 +1234,7 @@ impl ThreadManagerState { /*forked_from_thread_id*/ None, /*thread_source*/ None, /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, @@ -1234,6 +1252,7 @@ impl ThreadManagerState { forked_from_thread_id: Option, thread_source: Option, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1252,6 +1271,7 @@ impl ThreadManagerState { thread_source, Vec::new(), metrics_service_name, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1279,6 +1299,7 @@ impl ThreadManagerState { let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); let thread_source = initial_history.get_resumed_thread_source(); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.spawn_thread_with_source( config, initial_history, @@ -1290,6 +1311,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1311,6 +1333,7 @@ impl ThreadManagerState { thread_source: Option, parent_thread_id: Option, forked_from_thread_id: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1329,6 +1352,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1353,6 +1377,7 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, @@ -1370,6 +1395,7 @@ impl ThreadManagerState { thread_source, dynamic_tools, metrics_service_name, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -1394,6 +1420,7 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, parent_trace: Option, @@ -1473,6 +1500,7 @@ impl ThreadManagerState { attestation_provider: self.attestation_provider.clone(), external_time_provider: self.external_time_provider.clone(), inherited_multi_agent_version: multi_agent_version, + initial_multi_agent_mode, })) .await?; let new_thread = self diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index bfb7865bb219..d4515e87d8d1 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -322,6 +322,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), @@ -462,6 +463,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-a", "env-a"), @@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-b", "env-b"), @@ -569,6 +572,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: environments.clone(), thread_extension_init: Default::default(), @@ -852,6 +856,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { thread_source: Some(ThreadSource::User), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 204385934e2b..fcad4bb62f52 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -132,6 +132,7 @@ async fn handle_spawn_agent( fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: None, }, )) .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index eefea1eb8fed..eecdfc178d74 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -24,6 +24,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; @@ -1147,6 +1148,11 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + turn.multi_agent_mode = Some(MultiAgentMode::Proactive); set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1185,6 +1191,10 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat child_snapshot.session_source.get_agent_path().as_deref(), Some("/root/test_process") ); + assert_eq!( + child_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 51813c5c1991..ed63102d6aa1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -50,6 +50,15 @@ async fn handle_spawn_agent( let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; let fork_mode = args.fork_mode()?; + let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( + turn.multi_agent_version, + &turn.config.multi_agent_v2, + &turn.session_source, + turn.multi_agent_mode, + turn.config + .features + .enabled(codex_features::Feature::MultiAgentMode), + ); let role_name = args .agent_type .as_deref() @@ -134,6 +143,7 @@ async fn handle_spawn_agent( fork_mode, parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: multi_agent_mode, }, ), ) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index e0e7b20287f2..d843e0097bc8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -641,6 +641,7 @@ impl TestCodexBuilder { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: Default::default(), diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index e3a08447c5ff..498a6660794a 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -441,6 +441,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), @@ -648,6 +649,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: vec![ TurnEnvironmentSelection { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a31d7e36163a..2b9395f598e1 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -751,6 +751,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 526c5fc48782..4e341d773b57 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -790,5 +790,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { }, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), } } diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 6cc856ddb407..0835e155205b 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -311,6 +311,7 @@ impl MemoryStartupContext { thread_source: Some(ThreadSource::MemoryConsolidation), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: Default::default(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bc0186bc64fe..1fe732c4ffa6 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1992,6 +1992,8 @@ pub struct ThreadSettingsSnapshot { #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, pub collaboration_mode: CollaborationMode, + #[serde(default)] + pub multi_agent_mode: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] @@ -2554,12 +2556,24 @@ impl InitialHistory { } } - pub fn get_multi_agent_mode(&self) -> Option { - match self { - InitialHistory::New | InitialHistory::Cleared => None, - InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history), - InitialHistory::Forked(items) => multi_agent_mode_from_items(items), - } + pub fn get_latest_effective_multi_agent_mode(&self) -> Option { + let items = match self { + InitialHistory::New | InitialHistory::Cleared => return None, + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, + }; + items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => Some(turn_context), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + .and_then(|turn_context| turn_context.multi_agent_mode) } pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { @@ -2874,17 +2888,6 @@ fn multi_agent_version_from_items( }) } -fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option { - items.iter().rev().find_map(|item| match item { - RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode, - RolloutItem::SessionMeta(_) - | RolloutItem::ResponseItem(_) - | RolloutItem::InterAgentCommunication(_) - | RolloutItem::Compacted(_) - | RolloutItem::EventMsg(_) => None, - }) -} - #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -5399,6 +5402,31 @@ mod tests { Ok(()) } + #[test] + fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> { + let turn_context_item = |multi_agent_mode| -> Result { + let mut value = json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto", + }); + value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?; + Ok(RolloutItem::TurnContext(serde_json::from_value(value)?)) + }; + + assert_eq!( + InitialHistory::Forked(vec![ + turn_context_item(Some(MultiAgentMode::Proactive))?, + turn_context_item(/*multi_agent_mode*/ None)?, + ]) + .get_latest_effective_multi_agent_mode(), + None + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index e29d0e9d5d3c..7e84e4227187 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -230,6 +230,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 51151206fa90..0fc561cb2aa1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6038,6 +6038,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, }; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 365ee92fcafc..9ed78fed58d6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2381,6 +2381,7 @@ mod tests { .into(), active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 383ab43053ad..a10c2cca9346 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -30,6 +30,7 @@ fn thread_settings_for_test( developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, }