Compare commits

..

9 Commits

Author SHA1 Message Date
Dylan Hurd
15a7addd30 fix rebase and compaction 2026-03-02 14:10:14 -07:00
Dylan Hurd
2dd562b2f1 Clarify on-request rule shell feature limits
Document that advanced shell features are excluded from rule evaluation, and stop listing subshell boundaries as independent command segments.

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 13:43:54 -07:00
Dylan Hurd
3eba5057f8 fix(core) Move approved rules to env context 2026-03-02 13:43:54 -07:00
Josh McKinney
75e7c804ea test(app-server): increase flow test timeout to reduce flake (#11814)
## Summary
- increase `DEFAULT_READ_TIMEOUT` in `codex_message_processor_flow` from
20s to 45s
- keep test behavior the same while avoiding platform timing flakes

## Why
Windows ARM64 CI showed these tests taking about 24s before
`task_complete`, which could fail early and produce wiremock
request-count mismatches.

## Testing
- just fmt
- cargo test -p codex-app-server codex_message_processor_flow --
--nocapture
2026-03-02 12:29:28 -08:00
Dylan Hurd
e10df4ba10 fix(core) shell_snapshot multiline exports (#12642)
## Summary
Codex discovered this one - shell_snapshot tests were breaking on my
machine because I had a multiline env var. We should handle these!

## Testing
- [x] existing tests pass
- [x] Updated unit tests
2026-03-02 12:08:17 -07:00
jif-oai
f8838fd6f3 feat: enable ma through /agent (#13246)
<img width="639" height="139" alt="Screenshot 2026-03-02 at 16 06 41"
src="https://github.com/user-attachments/assets/c006fcec-c1e7-41ce-bb84-c121d5ffb501"
/>

Then
<img width="372" height="37" alt="Screenshot 2026-03-02 at 16 06 49"
src="https://github.com/user-attachments/assets/aa4ad703-e7e7-4620-9032-f5cd4f48ff79"
/>
2026-03-02 18:37:29 +00:00
Charley Cunningham
7979ce453a tui: restore draft footer hints (#13202)
## Summary
- restore `Tab to queue` when a draft is present and the agent is
running
- keep draft-idle footers passive by showing the normal footer or status
line instead of `? for shortcuts`
- align footer snapshot coverage with the updated draft footer behavior

## Codex author
`codex resume 019c7f1c-43aa-73e0-97c7-40f457396bb0`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-02 10:26:13 -08:00
Eric Traut
7709bf32a3 Fix project trust config parsing so CLI overrides work (#13090)
Fixes #13076

This PR fixes a bug that causes command-line config overrides for MCP
subtables to not be merged correctly.

Summary
- make project trust loading go through the dedicated struct so CLI
overrides can update trusted project-local MCP transports

---------

Co-authored-by: jif-oai <jif@openai.com>
2026-03-02 11:10:38 -07:00
Michael Bolin
3241c1c6cc fix: use https://git.savannah.gnu.org/git/bash instead of https://github.com/bolinfest/bash (#13057)
Historically, we cloned the Bash repo from
https://github.com/bminor/bash, but for whatever reason, it was removed
at some point.

I had a local clone of it, so I pushed it to
https://github.com/bolinfest/bash so that we could continue running our
CI job. I did this in https://github.com/openai/codex/pull/9563, and as
you can see, I did not tamper with the commit hash we used as the basis
of this build.

Using a personal fork is not great, so this PR changes the CI job to use
what appears to be considered the source of truth for Bash, which is
https://git.savannah.gnu.org/git/bash.git.

Though in testing this out, it appears this Git server does not support
the combination of `git clone --depth 1
https://git.savannah.gnu.org/git/bash` and `git fetch --depth 1 origin
a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b`, as it fails with the
following error:

```
error: Server does not allow request for unadvertised object a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
```

so unfortunately this means that we have to do a full clone instead of a
shallow clone in our CI jobs, which will be a bit slower.

Also updated `codex-rs/shell-escalation/README.md` to reflect this
change.
2026-03-02 09:09:54 -08:00
29 changed files with 589 additions and 103 deletions

View File

@@ -146,9 +146,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
@@ -188,9 +187,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc

View File

@@ -36,7 +36,7 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {

View File

@@ -625,6 +625,7 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
network: None,
approved_prefix_rules: None,
model: model.to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -207,13 +207,12 @@ 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 = [\"{notify_command}\", {}]",
"notify = [\"python3\", {}]",
toml_basic_string(notify_script)
),
)?;
@@ -262,12 +261,7 @@ tmp_path.replace(payload_path)
)
.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?;
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).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

@@ -155,6 +155,7 @@ use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
use codex_config::CONFIG_TOML_FILE;
use codex_execpolicy::Policy;
mod rollout_reconstruction;
#[cfg(test)]
@@ -770,7 +771,24 @@ impl TurnContext {
.unwrap_or(compact::SUMMARIZATION_PROMPT)
}
#[cfg(test)]
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
self.to_turn_context_item_with_approved_prefix_rules(None)
}
pub(crate) fn to_turn_context_item_with_exec_policy(
&self,
exec_policy: &Policy,
) -> TurnContextItem {
self.to_turn_context_item_with_approved_prefix_rules(format_allow_prefixes(
exec_policy.get_allowed_prefixes(),
))
}
fn to_turn_context_item_with_approved_prefix_rules(
&self,
approved_prefix_rules: Option<String>,
) -> TurnContextItem {
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
cwd: self.cwd.clone(),
@@ -779,6 +797,7 @@ impl TurnContext {
approval_policy: self.approval_policy.value(),
sandbox_policy: self.sandbox_policy.get().clone(),
network: self.turn_context_network_item(),
approved_prefix_rules,
model: self.model_info.slug.clone(),
personality: self.personality,
collaboration_mode: Some(self.collaboration_mode.clone()),
@@ -1787,7 +1806,11 @@ impl Session {
self.record_conversation_items(&turn_context, &items).await;
{
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
state.set_reference_context_item(Some(
turn_context.to_turn_context_item_with_exec_policy(
self.services.exec_policy.current().as_ref(),
),
));
}
self.set_previous_turn_settings(None).await;
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
@@ -1900,7 +1923,11 @@ impl Session {
.await;
{
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
state.set_reference_context_item(Some(
turn_context.to_turn_context_item_with_exec_policy(
self.services.exec_policy.current().as_ref(),
),
));
}
// Forked threads should remain file-backed immediately after startup.
@@ -3087,9 +3114,13 @@ impl Session {
.format_environment_context_subagents(self.conversation_id)
.await;
contextual_user_sections.push(
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
.with_subagents(subagents)
.serialize_to_xml(),
EnvironmentContext::from_turn_context(
turn_context,
shell.as_ref(),
format_allow_prefixes(self.services.exec_policy.current().get_allowed_prefixes()),
)
.with_subagents(subagents)
.serialize_to_xml(),
);
let mut items = Vec::with_capacity(2);
@@ -3157,7 +3188,8 @@ impl Session {
self.build_settings_update_items(reference_context_item.as_ref(), turn_context)
.await
};
let turn_context_item = turn_context.to_turn_context_item();
let turn_context_item = turn_context
.to_turn_context_item_with_exec_policy(self.services.exec_policy.current().as_ref());
if !context_items.is_empty() {
self.record_conversation_items(turn_context, &context_items)
.await;
@@ -7463,6 +7495,7 @@ mod tests {
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),

View File

@@ -46,6 +46,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -84,6 +85,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -745,6 +747,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -816,6 +819,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -844,6 +848,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -949,6 +954,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: current_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1050,6 +1056,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
@@ -1193,6 +1200,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),

View File

@@ -211,7 +211,11 @@ async fn run_compact_task_inner(
new_history.extend(ghost_snapshots);
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
InitialContextInjection::BeforeLastUserMessage => {
Some(turn_context.to_turn_context_item_with_exec_policy(
sess.services.exec_policy.current().as_ref(),
))
}
};
let compacted_item = CompactedItem {
message: summary_text.clone(),

View File

@@ -135,7 +135,11 @@ async fn run_remote_compact_task_inner_impl(
}
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
InitialContextInjection::BeforeLastUserMessage => {
Some(turn_context.to_turn_context_item_with_exec_policy(
sess.services.exec_policy.current().as_ref(),
))
}
};
let compacted_item = CompactedItem {
message: String::new(),

View File

@@ -6,7 +6,6 @@ mod macos;
mod tests;
use crate::config::ConfigToml;
use crate::config::deserialize_config_toml_with_base;
use crate::config_loader::layer_io::LoadedConfigLayers;
use crate::git_info::resolve_root_git_project_for_trust;
use codex_app_server_protocol::ConfigLayerSource;
@@ -576,6 +575,11 @@ struct ProjectTrustContext {
user_config_file: AbsolutePathBuf,
}
#[derive(Deserialize)]
struct ProjectTrustConfigToml {
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
}
struct ProjectTrustDecision {
trust_level: Option<TrustLevel>,
trust_key: String,
@@ -666,10 +670,16 @@ async fn project_trust_context(
config_base_dir: &Path,
user_config_file: &AbsolutePathBuf,
) -> io::Result<ProjectTrustContext> {
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
let project_trust_config: ProjectTrustConfigToml = {
let _guard = AbsolutePathBufGuard::new(config_base_dir);
merged_config
.clone()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
};
let project_root = find_project_root(cwd, project_root_markers).await?;
let projects = config_toml.projects.unwrap_or_default();
let projects = project_trust_config.projects.unwrap_or_default();
let project_root_key = project_root.as_path().to_string_lossy().to_string();
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());

View File

@@ -1114,6 +1114,91 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted()
-> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
[mcp_servers.sentry]
url = "https://mcp.sentry.dev/mcp"
enabled = false
"#,
)
.await?;
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.cli_overrides(vec![(
"mcp_servers.sentry.enabled".to_string(),
TomlValue::Boolean(true),
)])
.fallback_cwd(Some(nested))
.build()
.await?;
let server = config
.mcp_servers
.get()
.get("sentry")
.expect("trusted project MCP server should load");
assert!(server.enabled);
Ok(())
}
#[tokio::test]
async fn cli_override_for_disabled_project_local_mcp_server_returns_invalid_transport()
-> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
[mcp_servers.sentry]
url = "https://mcp.sentry.dev/mcp"
enabled = false
"#,
)
.await?;
let err = ConfigBuilder::default()
.codex_home(codex_home)
.cli_overrides(vec![(
"mcp_servers.sentry.enabled".to_string(),
TomlValue::Boolean(true),
)])
.fallback_cwd(Some(nested))
.build()
.await
.expect_err("untrusted project layer should not provide MCP transport");
assert!(
err.to_string().contains("invalid transport")
&& err.to_string().contains("mcp_servers.sentry"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> {
let tmp = tempdir()?;

View File

@@ -8,6 +8,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnContextItem;
@@ -15,16 +16,19 @@ fn build_environment_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
shell: &Shell,
exec_policy: &Policy,
) -> Option<ResponseItem> {
let prev = previous?;
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell);
let next_context = EnvironmentContext::from_turn_context(next, shell);
let approved_prefix_rules = format_allow_prefixes(exec_policy.get_allowed_prefixes());
let next_context =
EnvironmentContext::from_turn_context(next, shell, approved_prefix_rules.clone());
if prev_context.equals_except_shell(&next_context) {
return None;
}
Some(ResponseItem::from(
EnvironmentContext::diff_from_turn_context_item(prev, next, shell),
EnvironmentContext::diff_from_turn_context_item(prev, next, shell, approved_prefix_rules),
))
}
@@ -181,7 +185,7 @@ pub(crate) fn build_settings_update_items(
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
let contextual_user_message = build_environment_update_item(previous, next, shell);
let contextual_user_message = build_environment_update_item(previous, next, shell, exec_policy);
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.

View File

@@ -16,6 +16,7 @@ pub(crate) struct EnvironmentContext {
pub current_date: Option<String>,
pub timezone: Option<String>,
pub network: Option<NetworkContext>,
pub approved_prefix_rules: Option<String>,
pub subagents: Option<String>,
}
@@ -32,6 +33,7 @@ impl EnvironmentContext {
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
approved_prefix_rules: Option<String>,
subagents: Option<String>,
) -> Self {
Self {
@@ -40,6 +42,7 @@ impl EnvironmentContext {
current_date,
timezone,
network,
approved_prefix_rules,
subagents,
}
}
@@ -53,6 +56,7 @@ impl EnvironmentContext {
current_date,
timezone,
network,
approved_prefix_rules,
subagents,
shell: _,
} = other;
@@ -60,6 +64,7 @@ impl EnvironmentContext {
&& self.current_date == *current_date
&& self.timezone == *timezone
&& self.network == *network
&& self.approved_prefix_rules == *approved_prefix_rules
&& self.subagents == *subagents
}
@@ -67,9 +72,11 @@ impl EnvironmentContext {
before: &TurnContextItem,
after: &TurnContext,
shell: &Shell,
approved_prefix_rules: Option<String>,
) -> Self {
let before_network = Self::network_from_turn_context_item(before);
let after_network = Self::network_from_turn_context(after);
let before_approved_prefix_rules = before.approved_prefix_rules.clone();
let cwd = if before.cwd != after.cwd {
Some(after.cwd.clone())
} else {
@@ -82,16 +89,34 @@ impl EnvironmentContext {
} else {
before_network
};
EnvironmentContext::new(cwd, shell.clone(), current_date, timezone, network, None)
let approved_prefix_rules = if before_approved_prefix_rules != approved_prefix_rules {
approved_prefix_rules
} else {
before_approved_prefix_rules
};
EnvironmentContext::new(
cwd,
shell.clone(),
current_date,
timezone,
network,
approved_prefix_rules,
None,
)
}
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
pub fn from_turn_context(
turn_context: &TurnContext,
shell: &Shell,
approved_prefix_rules: Option<String>,
) -> Self {
Self::new(
Some(turn_context.cwd.clone()),
shell.clone(),
turn_context.current_date.clone(),
turn_context.timezone.clone(),
Self::network_from_turn_context(turn_context),
approved_prefix_rules,
None,
)
}
@@ -103,6 +128,7 @@ impl EnvironmentContext {
turn_context_item.current_date.clone(),
turn_context_item.timezone.clone(),
Self::network_from_turn_context_item(turn_context_item),
turn_context_item.approved_prefix_rules.clone(),
None,
)
}
@@ -183,6 +209,13 @@ impl EnvironmentContext {
// lines.push(" <network enabled=\"false\" />".to_string());
}
}
if let Some(approved_prefix_rules) = self.approved_prefix_rules {
lines.push(" <approved_prefix_rules>".to_string());
for line in approved_prefix_rules.lines() {
lines.push(format!(" {line}"));
}
lines.push(" </approved_prefix_rules>".to_string());
}
if let Some(subagents) = self.subagents {
lines.push(" <subagents>".to_string());
lines.extend(subagents.lines().map(|line| format!(" {line}")));
@@ -224,6 +257,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
None,
None,
None,
);
let expected = format!(
@@ -252,6 +286,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
Some(network),
None,
None,
);
let expected = format!(
@@ -281,6 +316,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
None,
None,
None,
);
let expected = r#"<environment_context>
@@ -301,6 +337,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
None,
None,
None,
);
let expected = r#"<environment_context>
@@ -321,6 +358,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
None,
None,
None,
);
let expected = r#"<environment_context>
@@ -341,6 +379,7 @@ mod tests {
Some("America/Los_Angeles".to_string()),
None,
None,
None,
);
let expected = r#"<environment_context>
@@ -352,6 +391,35 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_environment_context_with_approved_prefix_rules() {
let context = EnvironmentContext::new(
Some(test_path_buf("/repo")),
fake_shell(),
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
Some("- [\"mkdir\"]\n- [\"gh\", \"api\"]".to_string()),
None,
);
let expected = format!(
r#"<environment_context>
<cwd>{}</cwd>
<shell>bash</shell>
<current_date>2026-02-26</current_date>
<timezone>America/Los_Angeles</timezone>
<approved_prefix_rules>
- ["mkdir"]
- ["gh", "api"]
</approved_prefix_rules>
</environment_context>"#,
test_path_buf("/repo").display()
);
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn equals_except_shell_compares_cwd() {
let context1 = EnvironmentContext::new(
@@ -361,6 +429,7 @@ mod tests {
None,
None,
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -369,6 +438,7 @@ mod tests {
None,
None,
None,
None,
);
assert!(context1.equals_except_shell(&context2));
}
@@ -382,6 +452,7 @@ mod tests {
None,
None,
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -390,6 +461,7 @@ mod tests {
None,
None,
None,
None,
);
assert!(context1.equals_except_shell(&context2));
@@ -404,6 +476,7 @@ mod tests {
None,
None,
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo2")),
@@ -412,6 +485,7 @@ mod tests {
None,
None,
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -430,6 +504,7 @@ mod tests {
None,
None,
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -442,6 +517,7 @@ mod tests {
None,
None,
None,
None,
);
assert!(context1.equals_except_shell(&context2));
@@ -455,6 +531,7 @@ mod tests {
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
None,
None,
Some("- agent-1: atlas\n- agent-2".to_string()),
);
@@ -474,4 +551,28 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn equals_except_shell_compares_approved_prefix_rules() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
Some("- [\"mkdir\"]".to_string()),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
fake_shell(),
None,
None,
None,
Some("- [\"gh\", \"api\"]".to_string()),
None,
);
assert!(!context1.equals_except_shell(&context2));
}
}

View File

@@ -1401,6 +1401,7 @@ mod tests {
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
approved_prefix_rules: None,
model: "test-model".to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -360,19 +360,17 @@ alias_count=$(alias -p | wc -l | tr -d ' ')
echo "# aliases $alias_count"
alias -p
echo ''
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
line=$0
name=line
sub(/^(export|declare -x|typeset -x) /, "", name)
sub(/=.*/, "", name)
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
next
}
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
print line
}
}')
export_lines=$(
while IFS= read -r name; do
if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then
continue
fi
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
continue
fi
declare -xp "$name" 2>/dev/null || true
done < <(compgen -e)
)
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# exports $export_count"
if [ -n "$export_lines" ]; then
@@ -671,6 +669,46 @@ mod tests {
Ok(())
}
#[cfg(unix)]
#[test]
fn bash_snapshot_preserves_multiline_exports() -> Result<()> {
let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----";
let output = Command::new("/bin/bash")
.arg("-c")
.arg(bash_snapshot_script())
.env("BASH_ENV", "/dev/null")
.env("MULTILINE_CERT", multiline_cert)
.output()?;
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"),
"snapshot should include the multiline export name"
);
let dir = tempdir()?;
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(&snapshot_path, stdout.as_bytes())?;
let validate = Command::new("/bin/bash")
.arg("-c")
.arg("set -e; . \"$1\"")
.arg("bash")
.arg(&snapshot_path)
.env("BASH_ENV", "/dev/null")
.output()?;
assert!(
validate.status.success(),
"snapshot validation failed: {}",
String::from_utf8_lossy(&validate.stderr)
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {

View File

@@ -33,6 +33,7 @@ fn resume_history(
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model: previous_model.to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -352,7 +352,7 @@ impl DeveloperInstructions {
pub fn from(
approval_policy: AskForApproval,
exec_policy: &Policy,
_exec_policy: &Policy,
request_permission_enabled: bool,
) -> DeveloperInstructions {
let on_request_instructions = || {
@@ -361,15 +361,7 @@ impl DeveloperInstructions {
} else {
APPROVAL_POLICY_ON_REQUEST_RULE
};
let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes());
match command_prefixes {
Some(prefixes) => {
format!(
"{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
)
}
None => on_request_rule.to_string(),
}
on_request_rule.to_string()
};
let text = match approval_policy {
AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
@@ -533,7 +525,10 @@ impl DeveloperInstructions {
SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(),
SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(),
};
let text = template.replace("{network_access}", &network_access.to_string());
let text = format!(
"{} Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`.",
template.replace("{network_access}", &network_access.to_string())
);
DeveloperInstructions::new(text)
}
@@ -575,6 +570,10 @@ pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
output = output[..byte_idx].to_string();
}
if output.is_empty() {
return None;
}
if truncated {
Some(format!("{output}{TRUNCATED_MARKER}"))
} else {
@@ -1331,7 +1330,7 @@ mod tests {
assert_eq!(
workspace_write,
DeveloperInstructions::new(
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted. Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`."
)
);
@@ -1339,7 +1338,7 @@ mod tests {
assert_eq!(
read_only,
DeveloperInstructions::new(
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted. Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`."
)
);
}
@@ -1408,8 +1407,8 @@ mod tests {
let text = instructions.into_text();
assert!(text.contains("prefix_rule"));
assert!(text.contains("Approved command prefixes"));
assert!(text.contains(r#"["git", "pull"]"#));
assert!(text.contains("<approved_prefix_rules>"));
assert!(!text.contains("Approved command prefixes"));
}
#[test]

View File

@@ -5,7 +5,6 @@ Commands are run outside the sandbox if they are approved by the user, or match
- Pipes: |
- Logical operators: &&, ||
- Command separators: ;
- Subshell boundaries: (...), $(...)
Each resulting segment is evaluated independently for sandbox restrictions and approval requirements.
@@ -19,6 +18,8 @@ This is treated as two command segments:
["tee", "output.txt"]
Commands that use more advanced shell features like redirection (>, >>, <), substitutions ($(...), ...), environment variables (FOO=bar), or wildcard patterns (*, ?) will not be evaluated against rules, to limit the scope of what an approved rule allows.
## How to request escalation
IMPORTANT: To request approval to execute a command that will require escalated privileges:

View File

@@ -2146,6 +2146,8 @@ pub struct TurnContextItem {
pub sandbox_policy: SandboxPolicy,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_prefix_rules: Option<String>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
@@ -3380,6 +3382,7 @@ mod tests {
allowed_domains: vec!["api.example.com".to_string()],
denied_domains: vec!["blocked.example.com".to_string()],
}),
approved_prefix_rules: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -20,7 +20,7 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
```bash
git clone https://github.com/bminor/bash
git clone https://git.savannah.gnu.org/git/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply /path/to/patches/bash-exec-wrapper.patch
./configure --without-bash-malloc

View File

@@ -258,6 +258,7 @@ mod tests {
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
network: None,
approved_prefix_rules: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
@@ -295,6 +296,7 @@ mod tests {
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
approved_prefix_rules: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -1201,6 +1201,15 @@ impl App {
}
}
let has_non_primary_agent_thread = self
.agent_picker_threads
.keys()
.any(|thread_id| Some(*thread_id) != self.primary_thread_id);
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
self.chat_widget.open_multi_agent_enable_prompt();
return;
}
if self.agent_picker_threads.is_empty() {
self.chat_widget
.add_info_message("No agents available yet.".to_string(), None);
@@ -3601,6 +3610,7 @@ mod tests {
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use assert_matches::assert_matches;
use codex_core::CodexAuth;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -3966,6 +3976,51 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
{
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
Ok(())
}
#[tokio::test]
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
let mut app = make_test_app().await;

View File

@@ -4108,10 +4108,10 @@ impl ChatComposer {
!footer_props.is_task_running && self.collaboration_mode_indicator.is_some();
let show_shortcuts_hint = match footer_props.mode {
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
| FooterMode::EscHint => false,
};
let show_queue_hint = match footer_props.mode {
FooterMode::ComposerHasDraft => footer_props.is_task_running,
@@ -4141,10 +4141,13 @@ impl ChatComposer {
.as_ref()
.map(|line| line.clone().dim());
let status_line_candidate = footer_props.status_line_enabled
&& matches!(
footer_props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
);
&& match footer_props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !footer_props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let mut truncated_status_line = if status_line_candidate {
status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
@@ -4210,7 +4213,7 @@ impl ChatComposer {
can_show_left_with_context(hint_rect, left_width, right_width);
let has_override =
self.footer_flash_visible() || self.footer_hint_override.is_some();
let single_line_layout = if has_override {
let single_line_layout = if has_override || status_line_active {
None
} else {
match footer_props.mode {

View File

@@ -172,10 +172,10 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
false
}
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
@@ -562,13 +562,18 @@ fn footer_from_props_lines(
show_shortcuts_hint: bool,
show_queue_hint: bool,
) -> Vec<Line<'static>> {
// If status line content is present, show it for base modes.
// If status line content is present, show it for passive composer states.
// Active draft states still prefer the queue hint over the passive status
// line so the footer stays actionable while a task is running.
if props.status_line_enabled
&& let Some(status_line) = &props.status_line_value
&& matches!(
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)
&& match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
}
{
return vec![status_line.clone().dim()];
}
@@ -601,6 +606,8 @@ fn footer_from_props_lines(
let state = LeftSideState {
hint: if show_queue_hint {
SummaryHintKind::QueueMessage
} else if show_shortcuts_hint {
SummaryHintKind::Shortcuts
} else {
SummaryHintKind::None
},
@@ -1013,10 +1020,10 @@ mod tests {
let show_cycle_hint = !props.is_task_running;
let show_shortcuts_hint = match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => false,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint
| FooterMode::ComposerHasDraft => false,
| FooterMode::EscHint => false,
};
let show_queue_hint = match props.mode {
FooterMode::ComposerHasDraft => props.is_task_running,
@@ -1025,13 +1032,21 @@ mod tests {
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let left_mode_indicator = if props.status_line_enabled {
let status_line_active = props.status_line_enabled
&& match props.mode {
FooterMode::ComposerEmpty => true,
FooterMode::ComposerHasDraft => !props.is_task_running,
FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let left_mode_indicator = if status_line_active {
None
} else {
collaboration_mode_indicator
};
let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
let mut truncated_status_line = if props.status_line_enabled
let mut truncated_status_line = if status_line_active
&& matches!(
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
@@ -1044,7 +1059,7 @@ mod tests {
} else {
None
};
let mut left_width = if props.status_line_enabled {
let mut left_width = if status_line_active {
truncated_status_line
.as_ref()
.map(|line| line.width() as u16)
@@ -1058,7 +1073,7 @@ mod tests {
show_queue_hint,
)
};
let right_line = if props.status_line_enabled {
let right_line = if status_line_active {
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(collaboration_mode_indicator, false);
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
@@ -1077,7 +1092,7 @@ mod tests {
.as_ref()
.map(|line| line.width() as u16)
.unwrap_or(0);
if props.status_line_enabled
if status_line_active
&& let Some(max_left) = max_left_width_for_right(area, right_width)
&& left_width > max_left
&& let Some(line) = props
@@ -1097,21 +1112,24 @@ mod tests {
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
) {
let (summary_left, show_context) = single_line_footer_layout(
area,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
match summary_left {
SummaryLeft::Default => {
if props.status_line_enabled {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(area, f.buffer_mut(), line);
}
} else {
if status_line_active {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(area, f.buffer_mut(), line);
}
if can_show_left_and_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
} else {
let (summary_left, show_context) = single_line_footer_layout(
area,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
match summary_left {
SummaryLeft::Default => {
render_footer_from_props(
area,
f.buffer_mut(),
@@ -1122,14 +1140,14 @@ mod tests {
show_queue_hint,
);
}
SummaryLeft::Custom(line) => {
render_footer_line(area, f.buffer_mut(), line);
}
SummaryLeft::None => {}
}
SummaryLeft::Custom(line) => {
render_footer_line(area, f.buffer_mut(), line);
if show_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
SummaryLeft::None => {}
}
if show_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
}
} else {
render_footer_from_props(
@@ -1416,6 +1434,38 @@ mod tests {
snapshot_footer("footer_status_line_overrides_shortcuts", props);
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
};
snapshot_footer("footer_status_line_yields_to_queue_hint", props);
let props = FooterProps {
mode: FooterMode::ComposerHasDraft,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
};
snapshot_footer("footer_status_line_overrides_draft_idle", props);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Status line content "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" tab to queue message 100% context left "

View File

@@ -164,6 +164,10 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable multi-agent?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
const MULTI_AGENT_ENABLE_NOTICE: &str = "Multi-agent will be enabled in the next session.";
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
@@ -1568,6 +1572,41 @@ impl ChatWidget {
});
}
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
let items = vec![
SelectionItem {
name: MULTI_AGENT_ENABLE_YES.to_string(),
description: Some(
"Save the setting now. You will need a new session to use it.".to_string(),
),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::UpdateFeatureFlags {
updates: vec![(Feature::Collab, true)],
});
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()),
)));
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: MULTI_AGENT_ENABLE_NO.to_string(),
description: Some("Keep multi-agent disabled.".to_string()),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()),
subtitle: Some("Multi-agent is currently disabled in your config.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
match info {
Some(info) => self.apply_token_info(info),

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 6001
expression: popup
---
Enable multi-agent?
Multi-agent is currently disabled in your config.
1. Yes, enable Save the setting now. You will need a new session to use it.
2. Not now Keep multi-agent disabled.
Press enter to confirm or esc to go back

View File

@@ -5991,6 +5991,35 @@ async fn experimental_popup_shows_js_repl_node_requirement() {
);
}
#[tokio::test]
async fn multi_agent_enable_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_multi_agent_enable_prompt();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("multi_agent_enable_prompt", popup);
}
#[tokio::test]
async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_multi_agent_enable_prompt();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(120));
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
}
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;

View File

@@ -1277,6 +1277,7 @@ mod tests {
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
network: None,
approved_prefix_rules: None,
model,
personality: None,
collaboration_mode: None,