Compare commits

...

5 Commits

Author SHA1 Message Date
Ahmed Ibrahim
ad1a8040c7 fix: use platform python for notify test 2026-03-02 10:22:12 -07:00
jif-oai
9a42a56d8f chore: /multiagent alias for /agent (#13249)
Add a `/mutli-agents` alias for `/agent` and update the wording
2026-03-02 16:51:54 +00:00
daveaitel-openai
c2e126f92a core: reuse parent shell snapshot for thread-spawn subagents (#13052)
## Summary
- reuse the parent shell snapshot when spawning/forking/resuming
`SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })` sessions
- plumb inherited snapshot through `AgentControl -> ThreadManager ->
Codex::spawn -> SessionConfiguration`
- skip shell snapshot refresh on cwd updates for thread-spawn subagents
so inherited snapshots are not replaced

## Why
- avoids per-subagent shell snapshot creation and cleanup work
- keeps thread-spawn subagents on the parent snapshot path, matching the
intended parent/child snapshot model

## Validation
- `just fmt` (in `codex-rs`)
- `cargo test -p codex-core --no-run`
- `cargo test -p codex-core spawn_agent -- --nocapture`
- `cargo test -p codex-core --test all
suite::agent_jobs::spawn_agents_on_csv_runs_and_exports`

## Notes
- full `cargo test -p codex-core --test all` was left running separately
for broader verification

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 15:53:15 +00:00
jif-oai
2a5bcc053f fix: esc in /agent (#13131)
Fix https://github.com/openai/codex/issues/13093
2026-03-02 15:49:06 +00:00
jif-oai
1905597017 feat: update memories config names (#13237) 2026-03-02 15:25:39 +00:00
18 changed files with 253 additions and 71 deletions

View File

@@ -207,12 +207,13 @@ tmp_path.replace(payload_path)
let notify_script = notify_script
.to_str()
.expect("notify script path should be valid UTF-8");
let notify_command = if cfg!(windows) { "python" } else { "python3" };
create_config_toml_with_extra(
codex_home.path(),
&server.uri(),
"never",
&format!(
"notify = [\"python3\", {}]",
"notify = [\"{notify_command}\", {}]",
toml_basic_string(notify_script)
),
)?;
@@ -261,7 +262,12 @@ tmp_path.replace(payload_path)
)
.await??;
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).await?;
let notify_timeout = if cfg!(windows) {
Duration::from_secs(15)
} else {
Duration::from_secs(5)
};
fs_wait::wait_for_path_exists(&notify_file, notify_timeout).await?;
let payload_raw = tokio::fs::read_to_string(&notify_file).await?;
let payload: Value = serde_json::from_str(&payload_raw)?;
assert_eq!(payload["client"], "xcode");

View File

@@ -616,11 +616,19 @@
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
"properties": {
"consolidation_model": {
"description": "Model used for memory consolidation.",
"type": "string"
},
"extract_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"generate_memories": {
"description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.",
"type": "boolean"
},
"max_raw_memories_for_global": {
"max_raw_memories_for_consolidation": {
"description": "Maximum number of recent raw memories retained for global consolidation.",
"format": "uint",
"minimum": 0.0,
@@ -651,14 +659,6 @@
"description": "When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `\"polluted\"`.",
"type": "boolean"
},
"phase_1_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"phase_2_model": {
"description": "Model used for memory consolidation.",
"type": "string"
},
"use_memories": {
"description": "When `false`, skip injecting memory usage instructions into developer prompts.",
"type": "boolean"

View File

@@ -7,6 +7,7 @@ use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
use crate::session_prefix::format_subagent_context_line;
use crate::session_prefix::format_subagent_notification_message;
use crate::shell_snapshot::ShellSnapshot;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use codex_protocol::ThreadId;
@@ -83,6 +84,9 @@ impl AgentControl {
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, session_source.as_ref())
.await;
let session_source = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
@@ -161,6 +165,7 @@ impl AgentControl {
self.clone(),
session_source,
false,
inherited_shell_snapshot,
)
.await?
} else {
@@ -171,6 +176,7 @@ impl AgentControl {
session_source,
false,
None,
inherited_shell_snapshot,
)
.await?
}
@@ -235,6 +241,9 @@ impl AgentControl {
other => other,
};
let notification_source = session_source.clone();
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, Some(&session_source))
.await;
let rollout_path =
find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
.await?
@@ -246,6 +255,7 @@ impl AgentControl {
rollout_path,
self.clone(),
session_source,
inherited_shell_snapshot,
)
.await?;
reservation.commit(resumed_thread.thread_id);
@@ -431,6 +441,22 @@ impl AgentControl {
.upgrade()
.ok_or_else(|| CodexErr::UnsupportedOperation("thread manager dropped".to_string()))
}
async fn inherited_shell_snapshot_for_source(
&self,
state: &Arc<ThreadManagerState>,
session_source: Option<&SessionSource>,
) -> Option<Arc<ShellSnapshot>> {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return None;
};
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
parent_thread.codex.session.user_shell().shell_snapshot()
}
}
#[cfg(test)]
mod tests {

View File

@@ -345,6 +345,7 @@ impl Codex {
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -475,6 +476,7 @@ impl Codex {
session_source,
dynamic_tools,
persist_extended_history,
inherited_shell_snapshot,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -865,6 +867,7 @@ pub(crate) struct SessionConfiguration {
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
}
impl SessionConfiguration {
@@ -1383,13 +1386,19 @@ impl Session {
};
// Create the mutable state for the Session.
let shell_snapshot_tx = if config.features.enabled(Feature::ShellSnapshot) {
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
session_configuration.cwd.clone(),
&mut default_shell,
otel_manager.clone(),
)
if let Some(snapshot) = session_configuration.inherited_shell_snapshot.clone() {
let (tx, rx) = watch::channel(Some(snapshot));
default_shell.shell_snapshot = rx;
tx
} else {
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
session_configuration.cwd.clone(),
&mut default_shell,
otel_manager.clone(),
)
}
} else {
let (tx, rx) = watch::channel(None);
default_shell.shell_snapshot = rx;
@@ -1978,6 +1987,7 @@ impl Session {
previous_cwd: &Path,
next_cwd: &Path,
codex_home: &Path,
session_source: &SessionSource,
) {
if previous_cwd == next_cwd {
return;
@@ -1987,6 +1997,13 @@ impl Session {
return;
}
if matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) {
return;
}
ShellSnapshot::refresh_snapshot(
codex_home.to_path_buf(),
self.conversation_id,
@@ -2008,10 +2025,16 @@ impl Session {
let previous_cwd = state.session_configuration.cwd.clone();
let next_cwd = updated.cwd.clone();
let codex_home = updated.codex_home.clone();
let session_source = updated.session_source.clone();
state.session_configuration = updated;
drop(state);
self.maybe_refresh_shell_snapshot_for_cwd(&previous_cwd, &next_cwd, &codex_home);
self.maybe_refresh_shell_snapshot_for_cwd(
&previous_cwd,
&next_cwd,
&codex_home,
&session_source,
);
Ok(())
}
@@ -2027,7 +2050,13 @@ impl Session {
sub_id: String,
updates: SessionSettingsUpdate,
) -> ConstraintResult<Arc<TurnContext>> {
let (session_configuration, sandbox_policy_changed, previous_cwd, codex_home) = {
let (
session_configuration,
sandbox_policy_changed,
previous_cwd,
codex_home,
session_source,
) = {
let mut state = self.state.lock().await;
match state.session_configuration.clone().apply(&updates) {
Ok(next) => {
@@ -2035,8 +2064,15 @@ impl Session {
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != next.sandbox_policy;
let codex_home = next.codex_home.clone();
let session_source = next.session_source.clone();
state.session_configuration = next.clone();
(next, sandbox_policy_changed, previous_cwd, codex_home)
(
next,
sandbox_policy_changed,
previous_cwd,
codex_home,
session_source,
)
}
Err(err) => {
drop(state);
@@ -2057,6 +2093,7 @@ impl Session {
&previous_cwd,
&session_configuration.cwd,
&codex_home,
&session_source,
);
Ok(self
@@ -7667,6 +7704,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let mut state = SessionState::new(session_configuration);
@@ -7760,6 +7798,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let mut state = SessionState::new(session_configuration);
@@ -8072,6 +8111,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
}
}
@@ -8126,6 +8166,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -8216,6 +8257,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@@ -8383,6 +8425,7 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools,
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(

View File

@@ -62,6 +62,7 @@ pub(crate) async fn run_codex_thread_interactive(
Vec::new(),
false,
None,
None,
)
.await?;
let codex = Arc::new(codex);

View File

@@ -2508,13 +2508,13 @@ persistence = "none"
no_memories_if_mcp_or_web_search = true
generate_memories = false
use_memories = false
max_raw_memories_for_global = 512
max_raw_memories_for_consolidation = 512
max_unused_days = 21
max_rollout_age_days = 42
max_rollouts_per_startup = 9
min_rollout_idle_hours = 24
phase_1_model = "gpt-5-mini"
phase_2_model = "gpt-5"
extract_model = "gpt-5-mini"
consolidation_model = "gpt-5"
"#;
let memories_cfg =
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
@@ -2523,13 +2523,13 @@ phase_2_model = "gpt-5"
no_memories_if_mcp_or_web_search: Some(true),
generate_memories: Some(false),
use_memories: Some(false),
max_raw_memories_for_global: Some(512),
max_raw_memories_for_consolidation: Some(512),
max_unused_days: Some(21),
max_rollout_age_days: Some(42),
max_rollouts_per_startup: Some(9),
min_rollout_idle_hours: Some(24),
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5".to_string()),
}),
memories_cfg.memories
);
@@ -2546,13 +2546,13 @@ phase_2_model = "gpt-5"
no_memories_if_mcp_or_web_search: true,
generate_memories: false,
use_memories: false,
max_raw_memories_for_global: 512,
max_raw_memories_for_consolidation: 512,
max_unused_days: 21,
max_rollout_age_days: 42,
max_rollouts_per_startup: 9,
min_rollout_idle_hours: 24,
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5".to_string()),
}
);
}

View File

@@ -26,7 +26,7 @@ pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 256;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256;
pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
@@ -378,7 +378,7 @@ pub struct MemoriesToml {
/// When `false`, skip injecting memory usage instructions into developer prompts.
pub use_memories: Option<bool>,
/// Maximum number of recent raw memories retained for global consolidation.
pub max_raw_memories_for_global: Option<usize>,
pub max_raw_memories_for_consolidation: Option<usize>,
/// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
pub max_unused_days: Option<i64>,
/// Maximum age of the threads used for memories.
@@ -388,9 +388,9 @@ pub struct MemoriesToml {
/// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.
pub min_rollout_idle_hours: Option<i64>,
/// Model used for thread summarisation.
pub phase_1_model: Option<String>,
pub extract_model: Option<String>,
/// Model used for memory consolidation.
pub phase_2_model: Option<String>,
pub consolidation_model: Option<String>,
}
/// Effective memories settings after defaults are applied.
@@ -399,13 +399,13 @@ pub struct MemoriesConfig {
pub no_memories_if_mcp_or_web_search: bool,
pub generate_memories: bool,
pub use_memories: bool,
pub max_raw_memories_for_global: usize,
pub max_raw_memories_for_consolidation: usize,
pub max_unused_days: i64,
pub max_rollout_age_days: i64,
pub max_rollouts_per_startup: usize,
pub min_rollout_idle_hours: i64,
pub phase_1_model: Option<String>,
pub phase_2_model: Option<String>,
pub extract_model: Option<String>,
pub consolidation_model: Option<String>,
}
impl Default for MemoriesConfig {
@@ -414,13 +414,13 @@ impl Default for MemoriesConfig {
no_memories_if_mcp_or_web_search: false,
generate_memories: true,
use_memories: true,
max_raw_memories_for_global: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
phase_1_model: None,
phase_2_model: None,
extract_model: None,
consolidation_model: None,
}
}
}
@@ -434,9 +434,9 @@ impl From<MemoriesToml> for MemoriesConfig {
.unwrap_or(defaults.no_memories_if_mcp_or_web_search),
generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
max_raw_memories_for_global: toml
.max_raw_memories_for_global
.unwrap_or(defaults.max_raw_memories_for_global)
max_raw_memories_for_consolidation: toml
.max_raw_memories_for_consolidation
.unwrap_or(defaults.max_raw_memories_for_consolidation)
.min(4096),
max_unused_days: toml
.max_unused_days
@@ -454,8 +454,8 @@ impl From<MemoriesToml> for MemoriesConfig {
.min_rollout_idle_hours
.unwrap_or(defaults.min_rollout_idle_hours)
.clamp(1, 48),
phase_1_model: toml.phase_1_model,
phase_2_model: toml.phase_2_model,
extract_model: toml.extract_model,
consolidation_model: toml.consolidation_model,
}
}
}

View File

@@ -193,7 +193,7 @@ async fn claim_startup_jobs(
async fn build_request_context(session: &Arc<Session>, config: &Config) -> RequestContext {
let model_name = config
.memories
.phase_1_model
.extract_model
.clone()
.unwrap_or(phase_one::MODEL.to_string());
let model = session

View File

@@ -52,7 +52,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
return;
};
let root = memory_root(&config.codex_home);
let max_raw_memories = config.memories.max_raw_memories_for_global;
let max_raw_memories = config.memories.max_raw_memories_for_consolidation;
let max_unused_days = config.memories.max_unused_days;
// 1. Claim the job.
@@ -294,7 +294,7 @@ mod agent {
agent_config.model = Some(
config
.memories
.phase_2_model
.consolidation_model
.clone()
.unwrap_or(phase_two::MODEL.to_string()),
);

View File

@@ -13,21 +13,21 @@ use crate::memories::rollout_summaries_dir;
pub(super) async fn rebuild_raw_memories_file_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
rebuild_raw_memories_file(root, memories, max_raw_memories_for_global).await
rebuild_raw_memories_file(root, memories, max_raw_memories_for_consolidation).await
}
/// Syncs canonical rollout summary files from DB-backed stage-1 output rows.
pub(super) async fn sync_rollout_summaries_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
let retained = retained_memories(memories, max_raw_memories_for_global);
let retained = retained_memories(memories, max_raw_memories_for_consolidation);
let keep = retained
.iter()
.map(rollout_summary_file_stem)
@@ -62,9 +62,9 @@ pub(super) async fn sync_rollout_summaries_from_memories(
async fn rebuild_raw_memories_file(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()> {
let retained = retained_memories(memories, max_raw_memories_for_global);
let retained = retained_memories(memories, max_raw_memories_for_consolidation);
let mut body = String::from("# Raw Memories\n\n");
if retained.is_empty() {
@@ -155,9 +155,9 @@ async fn write_rollout_summary_for_thread(
fn retained_memories(
memories: &[Stage1Output],
max_raw_memories_for_global: usize,
max_raw_memories_for_consolidation: usize,
) -> &[Stage1Output] {
&memories[..memories.len().min(max_raw_memories_for_global)]
&memories[..memories.len().min(max_raw_memories_for_consolidation)]
}
fn raw_memories_format_error(err: std::fmt::Error) -> std::io::Error {

View File

@@ -1,6 +1,6 @@
use super::storage::rebuild_raw_memories_file_from_memories;
use super::storage::sync_rollout_summaries_from_memories;
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL;
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
use crate::memories::ensure_layout;
use crate::memories::memory_root;
use crate::memories::raw_memories_file;
@@ -95,14 +95,14 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
)
.await
.expect("rebuild raw memories");
@@ -201,7 +201,7 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename(
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
)
.await
.expect("sync rollout summaries");
@@ -304,14 +304,14 @@ task_outcome: success
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
)
.await
.expect("rebuild raw memories");

View File

@@ -20,6 +20,7 @@ use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
@@ -479,6 +480,7 @@ impl ThreadManagerState {
self.session_source.clone(),
false,
None,
None,
)
.await
}
@@ -490,6 +492,7 @@ impl ThreadManagerState {
session_source: SessionSource,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -500,6 +503,7 @@ impl ThreadManagerState {
Vec::new(),
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
)
.await
}
@@ -510,6 +514,7 @@ impl ThreadManagerState {
rollout_path: PathBuf,
agent_control: AgentControl,
session_source: SessionSource,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
self.spawn_thread_with_source(
@@ -521,6 +526,7 @@ impl ThreadManagerState {
Vec::new(),
false,
None,
inherited_shell_snapshot,
)
.await
}
@@ -532,6 +538,7 @@ impl ThreadManagerState {
agent_control: AgentControl,
session_source: SessionSource,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -542,6 +549,7 @@ impl ThreadManagerState {
Vec::new(),
persist_extended_history,
None,
inherited_shell_snapshot,
)
.await
}
@@ -567,6 +575,7 @@ impl ThreadManagerState {
dynamic_tools,
persist_extended_history,
metrics_service_name,
None,
)
.await
}
@@ -582,6 +591,7 @@ impl ThreadManagerState {
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
@@ -602,6 +612,7 @@ impl ThreadManagerState {
dynamic_tools,
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
)
.await?;
self.finalize_thread_spawn(codex, thread_id, watch_registration)

View File

@@ -168,7 +168,7 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs()
let mut initial_builder = test_codex().with_home(home.clone()).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_global = 1;
config.memories.max_raw_memories_for_consolidation = 1;
config.memories.no_memories_if_mcp_or_web_search = true;
});
let initial = initial_builder.build(&server).await?;
@@ -234,7 +234,7 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs()
let mut resumed_builder = test_codex().with_home(home.clone()).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_global = 1;
config.memories.max_raw_memories_for_consolidation = 1;
config.memories.no_memories_if_mcp_or_web_search = true;
});
let resumed = resumed_builder
@@ -313,7 +313,7 @@ async fn build_test_codex(server: &wiremock::MockServer, home: Arc<TempDir>) ->
let mut builder = test_codex().with_home(home).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_global = 1;
config.memories.max_raw_memories_for_consolidation = 1;
});
builder.build(server).await
}

View File

@@ -1246,8 +1246,8 @@ impl App {
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Agents".to_string()),
subtitle: Some("Select a thread to focus".to_string()),
title: Some("Multi-agents".to_string()),
subtitle: Some("Select an agent to watch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,

View File

@@ -342,7 +342,7 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
assert_eq!(cmds, vec!["model", "mention", "mcp", "multi-agents"]);
}
#[test]

View File

@@ -129,6 +129,7 @@ pub(crate) enum CancellationEvent {
NotHandled,
}
use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
@@ -398,11 +399,20 @@ impl BottomPane {
self.request_redraw();
InputResult::None
} else {
let is_agent_command = self
.composer_text()
.lines()
.next()
.and_then(parse_slash_name)
.is_some_and(|(name, _, _)| name == "agent");
// If a task is running and a status line is visible, allow Esc to
// send an interrupt even while the composer has focus.
// When a popup is active, prefer dismissing it over interrupting the task.
if key_event.code == KeyCode::Esc
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.is_task_running
&& !is_agent_command
&& !self.composer.popup_active()
&& let Some(status) = &self.status
{
@@ -1593,6 +1603,90 @@ mod tests {
assert_eq!(pane.composer_text(), "/");
}
#[test]
fn esc_with_agent_command_without_popup_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
// Repro: `/agent ` hides the popup (cursor past command name). Esc should
// keep editing command text instead of interrupting the running task.
pane.insert_str("/agent ");
assert!(
!pane.composer.popup_active(),
"expected command popup to be hidden after entering `/agent `"
);
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt while typing `/agent`"
);
}
assert_eq!(pane.composer_text(), "/agent ");
}
#[test]
fn esc_release_after_dismissing_agent_picker_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.show_selection_view(SelectionViewParams {
title: Some("Agents".to_string()),
items: vec![SelectionItem {
name: "Main".to_string(),
..Default::default()
}],
..Default::default()
});
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Press,
));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Release,
));
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc release after dismissing agent picker to not interrupt"
);
}
assert!(
pane.no_modal_or_popup_active(),
"expected Esc press to dismiss the agent picker"
);
}
#[test]
fn esc_interrupts_running_task_when_no_popup() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();

View File

@@ -3609,7 +3609,7 @@ impl ChatWidget {
}
self.open_collaboration_modes_popup();
}
SlashCommand::Agent => {
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {

View File

@@ -53,6 +53,7 @@ pub enum SlashCommand {
Realtime,
Settings,
TestApproval,
MultiAgents,
// Debugging commands.
#[strum(serialize = "debug-m-drop")]
MemoryDrop,
@@ -93,7 +94,7 @@ impl SlashCommand {
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent => "switch the active agent thread",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex is allowed to do",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
@@ -167,7 +168,7 @@ impl SlashCommand {
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
}