Compare commits

..

3 Commits

Author SHA1 Message Date
Cooper Gamble
49af46f7d9 Gate review-loop command behind feature flag 2026-03-02 14:14:29 -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
23 changed files with 1995 additions and 203 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

@@ -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

@@ -397,6 +397,9 @@
"responses_websockets_v2": {
"type": "boolean"
},
"review_loop": {
"type": "boolean"
},
"runtime_metrics": {
"type": "boolean"
},
@@ -1768,6 +1771,9 @@
"responses_websockets_v2": {
"type": "boolean"
},
"review_loop": {
"type": "boolean"
},
"runtime_metrics": {
"type": "boolean"
},

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

@@ -143,6 +143,8 @@ pub enum Feature {
/// Enable collaboration modes (Plan, Default).
/// Kept for config backward compatibility; behavior is always collaboration-modes-enabled.
CollaborationModes,
/// Enable `/review-loop` command.
ReviewLoop,
/// Enable personality selection in the TUI.
Personality,
/// Enable voice transcription in the TUI composer.
@@ -660,6 +662,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ReviewLoop,
key: "review_loop",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::VoiceTranscription,
key: "voice_transcription",

View File

@@ -18,6 +18,8 @@ const BASE_BRANCH_PROMPT: &str = "Review the code changes against the base branc
const COMMIT_PROMPT_WITH_TITLE: &str = "Review the code changes introduced by commit {sha} (\"{title}\"). Provide prioritized, actionable findings.";
const COMMIT_PROMPT: &str =
"Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
const COMMIT_FOLLOW_UP_PROMPT_WITH_TITLE: &str = "Review the cumulative code changes from {sha}^ through HEAD, rooted at commit {sha} (\"{title}\"). Run `git diff {sha}^ HEAD` to inspect the updated stack. Provide prioritized, actionable findings.";
const COMMIT_FOLLOW_UP_PROMPT: &str = "Review the cumulative code changes from {sha}^ through HEAD, rooted at commit {sha}. Run `git diff {sha}^ HEAD` to inspect the updated stack. Provide prioritized, actionable findings.";
pub fn resolve_review_request(
request: ReviewRequest,
@@ -67,6 +69,46 @@ pub fn review_prompt(target: &ReviewTarget, cwd: &Path) -> anyhow::Result<String
}
}
pub fn review_prompt_with_additional_instructions(
target: &ReviewTarget,
cwd: &Path,
additional_instructions: Option<&str>,
) -> anyhow::Result<String> {
let prompt = review_prompt(target, cwd)?;
Ok(append_additional_review_instructions(
prompt,
additional_instructions,
))
}
pub fn commit_follow_up_review_prompt(
sha: &str,
title: Option<&str>,
additional_instructions: Option<&str>,
) -> String {
let prompt = if let Some(title) = title {
COMMIT_FOLLOW_UP_PROMPT_WITH_TITLE
.replace("{sha}", sha)
.replace("{title}", title)
} else {
COMMIT_FOLLOW_UP_PROMPT.replace("{sha}", sha)
};
append_additional_review_instructions(prompt, additional_instructions)
}
fn append_additional_review_instructions(
prompt: String,
additional_instructions: Option<&str>,
) -> String {
let Some(additional_instructions) = additional_instructions.map(str::trim) else {
return prompt;
};
if additional_instructions.is_empty() {
return prompt;
}
format!("{prompt}\n\nAdditional review instructions:\n{additional_instructions}")
}
pub fn user_facing_hint(target: &ReviewTarget) -> String {
match target {
ReviewTarget::UncommittedChanges => "current changes".to_string(),
@@ -91,3 +133,43 @@ impl From<ResolvedReviewRequest> for ReviewRequest {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn review_prompt_with_additional_instructions_appends_header() {
let cwd = Path::new("/tmp");
let prompt = review_prompt_with_additional_instructions(
&ReviewTarget::UncommittedChanges,
cwd,
Some("Focus on migration safety."),
)
.expect("prompt");
assert_eq!(
prompt,
"Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.\n\nAdditional review instructions:\nFocus on migration safety."
);
}
#[test]
fn commit_follow_up_review_prompt_references_cumulative_stack() {
let prompt =
commit_follow_up_review_prompt("abc1234", Some("Add review loop"), Some("Find bugs."));
assert_eq!(
prompt,
"Review the cumulative code changes from abc1234^ through HEAD, rooted at commit abc1234 (\"Add review loop\"). Run `git diff abc1234^ HEAD` to inspect the updated stack. Provide prioritized, actionable findings.\n\nAdditional review instructions:\nFind bugs."
);
}
#[test]
fn commit_follow_up_review_prompt_omits_empty_additional_instructions() {
let prompt = commit_follow_up_review_prompt("abc1234", None, Some(" "));
assert_eq!(
prompt,
"Review the cumulative code changes from abc1234^ through HEAD, rooted at commit abc1234. Run `git diff abc1234^ HEAD` to inspect the updated stack. Provide prioritized, actionable findings."
);
}
}

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

@@ -3053,6 +3053,45 @@ impl App {
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::OpenReviewLoopSelector {
inline_instructions,
} => {
self.chat_widget.open_review_loop_popup(inline_instructions);
}
AppEvent::ReviewLoopTargetSelected {
target,
inline_instructions,
} => {
self.chat_widget
.on_review_loop_target_selected(target, inline_instructions);
}
AppEvent::OpenReviewLoopCapChooser => {
self.chat_widget.open_review_loop_cap_chooser();
}
AppEvent::OpenReviewLoopCapPrompt => {
self.chat_widget.open_review_loop_cap_prompt();
}
AppEvent::StartReviewLoop { max_fix_attempts } => {
self.chat_widget.start_review_loop(max_fix_attempts);
}
AppEvent::ReviewLoopInitialHeadResolved {
generation,
head_sha,
} => {
self.chat_widget
.on_review_loop_initial_head_resolved(generation, head_sha);
}
AppEvent::ReviewLoopCommitValidationResolved {
generation,
head_sha,
has_changes,
} => {
self.chat_widget.on_review_loop_commit_validation_resolved(
generation,
head_sha,
has_changes,
);
}
AppEvent::SubmitUserMessageWithMode {
text,
collaboration_mode,

View File

@@ -16,6 +16,7 @@ use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewTarget;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
@@ -407,6 +408,41 @@ pub(crate) enum AppEvent {
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Re-open the review-loop selector, preserving any inline instructions.
OpenReviewLoopSelector {
inline_instructions: Option<String>,
},
/// Continue the review-loop flow after a target is selected.
ReviewLoopTargetSelected {
target: ReviewTarget,
inline_instructions: Option<String>,
},
/// Open the review-loop cap chooser.
OpenReviewLoopCapChooser,
/// Open the review-loop numeric cap prompt.
OpenReviewLoopCapPrompt,
/// Start the configured review loop with the chosen max fix attempts.
StartReviewLoop {
max_fix_attempts: Option<u32>,
},
/// Async result for the initial HEAD lookup needed by commit-aware review loops.
ReviewLoopInitialHeadResolved {
generation: u64,
head_sha: Option<String>,
},
/// Async result for post-fix commit validation in commit-aware review loops.
ReviewLoopCommitValidationResolved {
generation: u64,
head_sha: Option<String>,
has_changes: Option<bool>,
},
/// Submit a user message with an explicit collaboration mask.
SubmitUserMessageWithMode {
text: String,

View File

@@ -396,6 +396,7 @@ pub(crate) struct ChatComposer {
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
review_loop_command_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
@@ -502,6 +503,7 @@ impl ChatComposer {
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
@@ -575,6 +577,10 @@ impl ChatComposer {
self.personality_command_enabled = enabled;
}
pub fn set_review_loop_command_enabled(&mut self, enabled: bool) {
self.review_loop_command_enabled = enabled;
}
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
self.realtime_conversation_enabled = enabled;
}
@@ -618,6 +624,31 @@ impl ChatComposer {
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
self.windows_degraded_sandbox_active = enabled;
}
fn slash_command_filters(&self) -> slash_commands::SlashCommandFilters {
slash_commands::SlashCommandFilters {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_enabled,
review_loop_command_enabled: self.review_loop_command_enabled,
allow_elevate_sandbox: !self.windows_degraded_sandbox_active,
}
}
fn slash_command_popup_flags(&self) -> CommandPopupFlags {
CommandPopupFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_enabled,
review_loop_command_enabled: self.review_loop_command_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 4] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -2257,16 +2288,9 @@ impl ChatComposer {
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
let is_builtin =
slash_commands::find_builtin_command(name, self.slash_command_filters())
.is_some();
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
@@ -2474,15 +2498,8 @@ impl ChatComposer {
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.slash_command_filters())
{
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
@@ -2510,15 +2527,7 @@ impl ChatComposer {
return None;
}
let cmd = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)?;
let cmd = slash_commands::find_builtin_command(name, self.slash_command_filters())?;
if !cmd.supports_inline_args() {
return None;
@@ -3330,16 +3339,8 @@ impl ChatComposer {
}
fn is_known_slash_name(&self, name: &str) -> bool {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
let is_builtin =
slash_commands::find_builtin_command(name, self.slash_command_filters()).is_some();
if is_builtin {
return true;
}
@@ -3393,15 +3394,7 @@ impl ChatComposer {
return rest_after_name.is_empty();
}
if slash_commands::has_builtin_prefix(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
) {
if slash_commands::has_builtin_prefix(name, self.slash_command_filters()) {
return true;
}
@@ -3450,21 +3443,9 @@ impl ChatComposer {
}
_ => {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
self.slash_command_popup_flags(),
);
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);

View File

@@ -41,23 +41,26 @@ pub(crate) struct CommandPopupFlags {
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) review_loop_command_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(
flags.collaboration_modes_enabled,
flags.connectors_enabled,
flags.personality_command_enabled,
flags.realtime_conversation_enabled,
flags.audio_device_selection_enabled,
flags.windows_degraded_sandbox_active,
)
.into_iter()
.filter(|(name, _)| !name.starts_with("debug"))
.collect();
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(slash_commands::SlashCommandFilters {
collaboration_modes_enabled: flags.collaboration_modes_enabled,
connectors_enabled: flags.connectors_enabled,
personality_command_enabled: flags.personality_command_enabled,
realtime_conversation_enabled: flags.realtime_conversation_enabled,
audio_device_selection_enabled: flags.audio_device_selection_enabled,
review_loop_command_enabled: flags.review_loop_command_enabled,
allow_elevate_sandbox: !flags.windows_degraded_sandbox_active,
})
.into_iter()
.filter(|(name, _)| !name.starts_with("debug"))
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
@@ -501,6 +504,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -522,6 +526,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -543,6 +548,7 @@ mod tests {
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -572,6 +578,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -593,6 +600,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -630,4 +638,58 @@ mod tests {
"expected no /debug* command in popup menu, got {cmds:?}"
);
}
#[test]
fn review_loop_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/review".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"review-loop"),
"expected '/review-loop' to be hidden when feature is disabled, got {cmds:?}"
);
}
#[test]
fn review_loop_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: true,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/review-loop".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "review-loop"),
other => {
panic!("expected review-loop to be selected for exact match, got {other:?}")
}
}
}
}

View File

@@ -22,7 +22,8 @@ use super::textarea::TextArea;
use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
pub(crate) type PromptSubmitted = Box<dyn Fn(String) -> Result<(), String> + Send + Sync>;
pub(crate) type PromptCancelled = Option<Box<dyn Fn() + Send + Sync>>;
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
@@ -30,10 +31,12 @@ pub(crate) struct CustomPromptView {
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
on_cancel: PromptCancelled,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
error_message: Option<String>,
complete: bool,
}
@@ -43,14 +46,17 @@ impl CustomPromptView {
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
on_cancel: PromptCancelled,
) -> Self {
Self {
title,
placeholder,
context_label,
on_submit,
on_cancel,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
error_message: None,
complete: false,
}
}
@@ -71,23 +77,30 @@ impl BottomPaneView for CustomPromptView {
} => {
let text = self.textarea.text().trim().to_string();
if !text.is_empty() {
(self.on_submit)(text);
self.complete = true;
match (self.on_submit)(text) {
Ok(()) => self.complete = true,
Err(err) => self.error_message = Some(err),
}
}
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.error_message = None;
self.textarea.input(key_event);
}
other => {
self.error_message = None;
self.textarea.input(other);
}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if let Some(on_cancel) = &self.on_cancel {
on_cancel();
}
self.complete = true;
CancellationEvent::Handled
}
@@ -100,6 +113,7 @@ impl BottomPaneView for CustomPromptView {
if pasted.is_empty() {
return false;
}
self.error_message = None;
self.textarea.insert_str(&pasted);
true
}
@@ -107,7 +121,11 @@ impl BottomPaneView for CustomPromptView {
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
let extra_top: u16 = if self.context_label.is_some() || self.error_message.is_some() {
1
} else {
0
};
1u16 + extra_top + self.input_height(width) + 3u16
}
@@ -130,7 +148,17 @@ impl Renderable for CustomPromptView {
// Optional context line
let mut input_y = area.y.saturating_add(1);
if let Some(context_label) = &self.context_label {
if let Some(error_message) = &self.error_message {
let context_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
let spans: Vec<Span<'static>> = vec![gutter(), error_message.clone().red()];
Paragraph::new(Line::from(spans)).render(context_area, buf);
input_y = input_y.saturating_add(1);
} else if let Some(context_label) = &self.context_label {
let context_area = Rect {
x: area.x,
y: input_y,
@@ -221,7 +249,11 @@ impl Renderable for CustomPromptView {
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let extra_offset: u16 = if self.error_message.is_some() || self.context_label.is_some() {
1
} else {
0
};
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
@@ -245,3 +277,70 @@ impl CustomPromptView {
fn gutter() -> Span<'static> {
"".cyan()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
#[test]
fn custom_prompt_cursor_uses_error_row_offset() {
let mut view = CustomPromptView::new(
"Set max fix attempts".to_string(),
"Type a positive integer and press Enter".to_string(),
None,
Box::new(|value: String| match value.parse::<u32>() {
Ok(value) if value > 0 => Ok(()),
Ok(_) | Err(_) => Err("Enter a positive integer.".to_string()),
}),
None,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let prompt_area = Rect::new(0, 0, 80, view.desired_height(80));
let (_x, y) = view
.cursor_pos(prompt_area)
.expect("cursor should be positioned when popup is active");
assert_eq!(y, 3);
}
#[test]
fn custom_prompt_cursor_uses_context_row_offset() {
let mut view = CustomPromptView::new(
"Custom prompt".to_string(),
"Type your input".to_string(),
Some("Optional context".to_string()),
Box::new(|_| Ok(())),
None,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
let prompt_area = Rect::new(0, 0, 80, view.desired_height(80));
let (_x, y) = view
.cursor_pos(prompt_area)
.expect("cursor should be positioned when popup is active");
assert_eq!(y, 3);
}
#[test]
fn custom_prompt_cursor_uses_basic_row_offset() {
let mut view = CustomPromptView::new(
"Custom prompt".to_string(),
"Type your input".to_string(),
None,
Box::new(|_| Ok(())),
None,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
let prompt_area = Rect::new(0, 0, 80, view.desired_height(80));
let (_x, y) = view
.cursor_pos(prompt_area)
.expect("cursor should be positioned when popup is active");
assert_eq!(y, 2);
}
}

View File

@@ -294,6 +294,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_review_loop_command_enabled(&mut self, enabled: bool) {
self.composer.set_review_loop_command_enabled(enabled);
self.request_redraw();
}
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
self.composer.set_realtime_conversation_enabled(enabled);
self.request_redraw();

View File

@@ -8,72 +8,52 @@ use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
#[derive(Clone, Copy, Debug)]
pub(crate) struct SlashCommandFilters {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) review_loop_command_enabled: bool,
pub(crate) allow_elevate_sandbox: bool,
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
filters: SlashCommandFilters,
) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| filters.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| {
collaboration_modes_enabled
filters.collaboration_modes_enabled
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| filters.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| filters.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| filters.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| filters.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| filters.review_loop_command_enabled || *cmd != SlashCommand::ReviewLoop)
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
filters: SlashCommandFilters,
) -> Option<SlashCommand> {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
builtins_for_input(filters)
.into_iter()
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
}
/// Whether any visible built-in fuzzily matches the provided prefix.
pub(crate) fn has_builtin_prefix(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
pub(crate) fn has_builtin_prefix(name: &str, filters: SlashCommandFilters) -> bool {
builtins_for_input(filters)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
}
#[cfg(test)]
@@ -83,39 +63,106 @@ mod tests {
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", true, true, true, false, false, false);
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
let cmd = find_builtin_command("debug-config", flags);
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}
#[test]
fn clear_command_resolves_for_dispatch() {
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
assert_eq!(
find_builtin_command("clear", true, true, true, false, false, false),
find_builtin_command("clear", flags),
Some(SlashCommand::Clear)
);
}
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("realtime", true, true, true, false, true, false),
None
);
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: true,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
assert_eq!(find_builtin_command("realtime", flags), None);
}
#[test]
fn settings_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("settings", true, true, true, false, false, false),
None
);
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
assert_eq!(find_builtin_command("settings", flags), None);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
assert_eq!(find_builtin_command("settings", flags), None);
}
#[test]
fn review_loop_command_is_hidden_when_disabled() {
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: false,
allow_elevate_sandbox: false,
};
assert_eq!(find_builtin_command("review-loop", flags), None);
}
#[test]
fn review_loop_command_is_visible_when_enabled() {
let flags = SlashCommandFilters {
collaboration_modes_enabled: true,
connectors_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
review_loop_command_enabled: true,
allow_elevate_sandbox: false,
};
assert_eq!(
find_builtin_command("settings", true, true, true, true, false, false),
None
find_builtin_command("review-loop", flags),
Some(SlashCommand::ReviewLoop)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests.rs
expression: "render_bottom_popup(&chat, 70)"
---
▌ Set max fix attempts
▌ Enter a positive integer.
▌ 0
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
• Review loop stopped after reaching the limit of 1 fix attempt.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
■ Review loop stopped because the fix turn ended with uncommitted changes and no new commit.

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: "render_bottom_popup(&chat, 70)"
---
Review loop settings
1. Run until clean Repeat until the reviewer returns no
findings.
2. Set max fix attempts Stop after a fixed number of fix/review
iterations.
Press enter to confirm or esc to go back

View File

@@ -29,6 +29,7 @@ use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::review_format::render_review_output_text;
use codex_core::skills::model::SkillMetadata;
use codex_core::terminal::TerminalName;
use codex_otel::OtelManager;
@@ -78,6 +79,10 @@ use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewCodeLocation;
use codex_protocol::protocol::ReviewFinding;
use codex_protocol::protocol::ReviewLineRange;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SessionSource;
@@ -1714,6 +1719,9 @@ async fn make_chatwidget_manual(
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
review_selector_mode: ReviewSelectorMode::SingleReview,
pending_review_loop_draft: None,
review_loop_state: None,
pre_review_token_info: None,
needs_final_message_separator: false,
had_work_activity: false,
@@ -1764,6 +1772,53 @@ fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
}
}
fn next_review_request(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> ReviewRequest {
loop {
match op_rx.try_recv() {
Ok(Op::Review { review_request }) => return review_request,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected a review op but queue was empty"),
Err(TryRecvError::Disconnected) => panic!("expected review op but channel closed"),
}
}
}
fn assert_no_review_or_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::Review { .. } | Op::UserTurn { .. }),
"unexpected follow-up op: {op:?}"
);
}
}
fn sample_review_output() -> ReviewOutputEvent {
ReviewOutputEvent {
findings: vec![ReviewFinding {
title: "Missing regression coverage".to_string(),
body: "The loop retries review passes but never verifies that the related tests still pass after applying fixes.".to_string(),
confidence_score: 0.93,
priority: 1,
code_location: ReviewCodeLocation {
absolute_file_path: PathBuf::from("/tmp/review-loop.rs"),
line_range: ReviewLineRange { start: 42, end: 42 },
},
}],
overall_correctness: "incorrect".to_string(),
overall_explanation: "The change needs follow-up before it is ready.".to_string(),
overall_confidence_score: 0.91,
}
}
fn no_findings_review_output() -> ReviewOutputEvent {
ReviewOutputEvent {
findings: Vec::new(),
overall_correctness: "correct".to_string(),
overall_explanation: "No additional issues found.".to_string(),
overall_confidence_score: 0.97,
}
}
fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
@@ -5358,6 +5413,577 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() {
);
}
#[tokio::test]
async fn slash_review_loop_is_blocked_when_feature_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::ReviewLoop);
let header = render_bottom_first_row(&chat, 60);
assert!(
header.contains("Ask Codex to do anything"),
"expected normal composer prompt after blocked review-loop command, got {header:?}"
);
assert!(chat.review_loop_state.is_none());
}
#[tokio::test]
async fn slash_review_loop_opens_review_selector() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::ReviewLoop, true);
chat.dispatch_command(SlashCommand::ReviewLoop);
let header = render_bottom_first_row(&chat, 60);
assert!(
header.contains("Select a review preset"),
"expected review selector header: {header:?}"
);
}
#[tokio::test]
async fn slash_review_loop_inline_args_are_preserved_for_selected_target() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::ReviewLoop, true);
chat.bottom_pane.set_composer_text(
"/review-loop focus on migration safety".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::ReviewLoopTargetSelected {
target: ReviewTarget::UncommittedChanges,
inline_instructions,
}) => {
assert_eq!(
inline_instructions,
Some("focus on migration safety".to_string())
);
}
other => panic!("expected ReviewLoopTargetSelected, got {other:?}"),
}
}
#[tokio::test]
async fn review_loop_custom_selection_uses_inline_prompt_without_extra_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::ReviewLoop, true);
chat.bottom_pane.set_composer_text(
"/review-loop inspect transaction boundaries".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::ReviewLoopTargetSelected {
target: ReviewTarget::Custom { instructions },
inline_instructions,
}) => {
assert_eq!(instructions, "inspect transaction boundaries");
assert_eq!(
inline_instructions,
Some("inspect transaction boundaries".to_string())
);
}
other => panic!("expected ReviewLoopTargetSelected, got {other:?}"),
}
}
#[tokio::test]
async fn review_loop_cap_chooser_unlimited_emits_start_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
assert_snapshot!("review_loop_settings_popup", render_bottom_popup(&chat, 70));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::StartReviewLoop { max_fix_attempts }) => {
assert_eq!(max_fix_attempts, None);
}
other => panic!("expected StartReviewLoop, got {other:?}"),
}
}
#[tokio::test]
async fn review_loop_cap_prompt_accepts_positive_integer() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.open_review_loop_cap_prompt();
chat.handle_paste("3".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::StartReviewLoop { max_fix_attempts }) => {
assert_eq!(max_fix_attempts, Some(3));
}
other => panic!("expected StartReviewLoop, got {other:?}"),
}
}
#[tokio::test]
async fn review_loop_cap_prompt_rejects_invalid_value() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.open_review_loop_cap_prompt();
chat.handle_paste("0".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(
rx.try_recv().is_err(),
"expected no app event for invalid cap"
);
assert_snapshot!(
"review_loop_cap_prompt_invalid",
render_bottom_popup(&chat, 70)
);
}
#[tokio::test]
async fn review_loop_stops_immediately_when_first_review_has_no_findings() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(None);
assert_eq!(
next_review_request(&mut op_rx),
ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}
);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(no_findings_review_output()),
});
chat.on_task_complete(None, false);
assert!(chat.review_loop_state.is_none());
assert_no_review_or_submit_op(&mut op_rx);
}
#[tokio::test]
async fn review_loop_findings_trigger_fix_turn_with_required_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(None);
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output.clone()),
});
chat.on_task_complete(None, false);
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
items,
vec![UserInput::Text {
text: format!(
"Please implement fixes for these findings and rerun the relevant test suites to ensure they are passing.\n\n{}",
render_review_output_text(&review_output)
),
text_elements: Vec::new(),
}]
);
}
#[tokio::test]
async fn review_loop_findings_trigger_fix_turn_with_default_mode_in_plan() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::CollaborationModes, true);
let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref())
.expect("plan collaboration mode should be available");
chat.set_collaboration_mask(plan_mask);
let review_output = sample_review_output();
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(None);
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
match next_submit_op(&mut op_rx) {
Op::UserTurn {
collaboration_mode:
Some(CollaborationMode {
mode: ModeKind::Default,
..
}),
..
} => {}
other => panic!("expected fix turn in default mode, got {other:?}"),
}
assert_eq!(chat.active_mode_kind(), ModeKind::Plan);
}
#[tokio::test]
async fn review_loop_commit_targets_append_commit_requirement_to_fix_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(
ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
None,
);
chat.start_review_loop(None);
let generation = chat
.review_loop_state
.as_ref()
.expect("loop state")
.generation;
chat.on_review_loop_initial_head_resolved(generation, Some("head-1".to_string()));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
let UserInput::Text {
text,
text_elements,
} = &items[0]
else {
panic!("expected text item in fix prompt");
};
assert_eq!(text_elements, &Vec::new());
assert!(
text.contains("Before finishing, commit the fixes in a new git commit so the next review pass includes them."),
"expected commit reminder in fix prompt: {text:?}"
);
}
#[tokio::test]
async fn review_loop_commit_follow_up_review_uses_cumulative_stack_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(
ReviewTarget::Commit {
sha: "abc1234".to_string(),
title: Some("Add review loop".to_string()),
},
Some("Focus on migration safety.".to_string()),
);
chat.start_review_loop(None);
let generation = chat
.review_loop_state
.as_ref()
.expect("loop state")
.generation;
chat.on_review_loop_initial_head_resolved(generation, Some("head-1".to_string()));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
chat.on_review_loop_commit_validation_resolved(
generation,
Some("head-2".to_string()),
Some(false),
);
let expected_prompt = codex_core::review_prompts::commit_follow_up_review_prompt(
"abc1234",
Some("Add review loop"),
Some("Focus on migration safety."),
);
assert_eq!(
next_review_request(&mut op_rx),
ReviewRequest {
target: ReviewTarget::Custom {
instructions: expected_prompt,
},
user_facing_hint: Some("stack since commit abc1234: Add review loop".to_string()),
}
);
}
#[tokio::test]
async fn review_loop_stops_when_fix_attempt_cap_is_reached() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(Some(1));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output.clone()),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
assert!(chat.review_loop_state.is_none());
assert_no_review_or_submit_op(&mut op_rx);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.find(|rendered| rendered.contains("Review loop stopped after reaching the limit"))
.expect("expected cap reached message");
assert_snapshot!("review_loop_cap_reached_info", rendered);
}
#[tokio::test]
async fn review_loop_queued_messages_wait_until_loop_completes() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(None);
let _ = next_review_request(&mut op_rx);
chat.queue_user_message("after the loop".to_string().into());
assert_eq!(chat.queued_user_messages.len(), 1);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let fix_items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected fix Op::UserTurn, got {other:?}"),
};
assert_ne!(
fix_items,
vec![UserInput::Text {
text: "after the loop".to_string(),
text_elements: Vec::new(),
}]
);
assert_eq!(chat.queued_user_messages.len(), 1);
chat.on_task_complete(None, false);
let _ = next_review_request(&mut op_rx);
assert_eq!(chat.queued_user_messages.len(), 1);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(no_findings_review_output()),
});
chat.on_task_complete(None, false);
let queued_items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected queued Op::UserTurn, got {other:?}"),
};
assert_eq!(
queued_items,
vec![UserInput::Text {
text: "after the loop".to_string(),
text_elements: Vec::new(),
}]
);
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn review_loop_commit_validation_failure_stops_with_error() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(
ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
None,
);
chat.start_review_loop(None);
let generation = chat
.review_loop_state
.as_ref()
.expect("loop state")
.generation;
chat.on_review_loop_initial_head_resolved(generation, Some("head-1".to_string()));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
chat.on_review_loop_commit_validation_resolved(
generation,
Some("head-1".to_string()),
Some(true),
);
assert!(chat.review_loop_state.is_none());
assert_no_review_or_submit_op(&mut op_rx);
let rendered = drain_insert_history(&mut rx)
.last()
.map(|lines| lines_to_single_string(lines))
.expect("expected commit validation error");
assert_snapshot!("review_loop_commit_validation_failure", rendered);
}
#[tokio::test]
async fn review_loop_commit_validation_stops_when_fix_is_dirty_with_new_commit() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(
ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
None,
);
chat.start_review_loop(None);
let generation = chat
.review_loop_state
.as_ref()
.expect("loop state")
.generation;
chat.on_review_loop_initial_head_resolved(generation, Some("head-1".to_string()));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
chat.on_review_loop_commit_validation_resolved(
generation,
Some("head-2".to_string()),
Some(true),
);
assert!(chat.review_loop_state.is_none());
assert_no_review_or_submit_op(&mut op_rx);
let rendered = drain_insert_history(&mut rx)
.last()
.map(|lines| lines_to_single_string(lines))
.expect("expected commit validation failure");
assert!(rendered.contains("created a new commit but left uncommitted changes."));
}
#[tokio::test]
async fn review_loop_state_survives_stream_error_during_fix_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(ReviewTarget::UncommittedChanges, None);
chat.start_review_loop(None);
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
let state = chat.review_loop_state.as_ref().expect("loop state");
assert_eq!(state.stage, ReviewLoopStage::Fixing);
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: Some("Idle timeout waiting for SSE".to_string()),
}),
});
let state = chat.review_loop_state.as_ref().expect("loop state");
assert_eq!(state.stage, ReviewLoopStage::Fixing);
chat.on_task_complete(None, false);
let _ = next_review_request(&mut op_rx);
}
#[tokio::test]
async fn review_loop_ignores_stale_commit_validation_callbacks() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
let review_output = sample_review_output();
chat.on_review_loop_target_selected(
ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
None,
);
chat.start_review_loop(None);
let generation = chat
.review_loop_state
.as_ref()
.expect("loop state")
.generation;
chat.on_review_loop_initial_head_resolved(generation, Some("head-1".to_string()));
let _ = next_review_request(&mut op_rx);
chat.on_exited_review_mode(ExitedReviewModeEvent {
review_output: Some(review_output),
});
chat.on_task_complete(None, false);
let _ = next_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
chat.on_review_loop_commit_validation_resolved(
generation.wrapping_add(1),
Some("head-2".to_string()),
Some(false),
);
let state = chat.review_loop_state.as_ref().expect("loop state");
assert_eq!(state.stage, ReviewLoopStage::AwaitingCommitValidation);
assert_eq!(state.baseline_head_sha.as_deref(), Some("head-1"));
}
fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);

View File

@@ -1093,6 +1093,11 @@ pub(crate) fn new_session_info(
"/review".into(),
" - review any changes and find issues".dim(),
]),
Line::from(vec![
" ".into(),
"/review-loop".into(),
" - run review/fix/review until clean".dim(),
]),
];
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));

View File

@@ -22,6 +22,7 @@ pub enum SlashCommand {
Experimental,
Skills,
Review,
ReviewLoop,
Rename,
New,
Resume,
@@ -70,6 +71,7 @@ impl SlashCommand {
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::ReviewLoop => "run review/fix/review until clean",
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
@@ -121,6 +123,7 @@ impl SlashCommand {
matches!(
self,
SlashCommand::Review
| SlashCommand::ReviewLoop
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::SandboxReadRoot
@@ -144,6 +147,7 @@ impl SlashCommand {
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::ReviewLoop
| SlashCommand::Plan
| SlashCommand::Clear
| SlashCommand::Logout