mirror of
https://github.com/openai/codex.git
synced 2026-03-03 05:03:20 +00:00
Compare commits
3 Commits
fix/notify
...
cooper/rev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49af46f7d9 | ||
|
|
7709bf32a3 | ||
|
|
3241c1c6cc |
6
.github/workflows/shell-tool-mcp.yml
vendored
6
.github/workflows/shell-tool-mcp.yml
vendored
@@ -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
|
||||
|
||||
@@ -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(¬ify_file, notify_timeout).await?;
|
||||
fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?;
|
||||
let payload_raw = tokio::fs::read_to_string(¬ify_file).await?;
|
||||
let payload: Value = serde_json::from_str(&payload_raw)?;
|
||||
assert_eq!(payload["client"], "xcode");
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
|
||||
• Review loop stopped after reaching the limit of 1 fix attempt.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user