mirror of
https://github.com/openai/codex.git
synced 2026-02-07 01:13:40 +00:00
Compare commits
7 Commits
remove/doc
...
jif/codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76582a8c9 | ||
|
|
15023e4728 | ||
|
|
132cfe97a5 | ||
|
|
d619a0dc05 | ||
|
|
8970cccbb1 | ||
|
|
67b66d1e08 | ||
|
|
50b8e856ab |
@@ -52,6 +52,23 @@ You can enable notifications by configuring a script that is run whenever the ag
|
||||
|
||||
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
|
||||
|
||||
You can also run the review flow headlessly:
|
||||
|
||||
```
|
||||
# Review uncommitted changes
|
||||
codex exec review --uncommitted
|
||||
|
||||
# Review changes against a base branch
|
||||
codex exec review --branch main
|
||||
|
||||
# Review a specific commit
|
||||
codex exec review --commit abcd123
|
||||
|
||||
# Custom review instructions (from arg or stdin)
|
||||
codex exec review "Review changes in src/ with focus on error handling"
|
||||
echo "Review all TODOs left in code" | codex exec review -
|
||||
```
|
||||
|
||||
### Experimenting with the Codex Sandbox
|
||||
|
||||
To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI:
|
||||
|
||||
@@ -58,6 +58,7 @@ pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod conversation_manager;
|
||||
mod event_mapping;
|
||||
pub mod review_format;
|
||||
pub mod review_prompts;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
|
||||
209
codex-rs/core/src/review_prompts.rs
Normal file
209
codex-rs/core/src/review_prompts.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use std::cmp::min;
|
||||
|
||||
/// Target to review against.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReviewTarget {
|
||||
/// Review the working tree: staged, unstaged, and untracked files.
|
||||
UncommittedChanges,
|
||||
|
||||
/// Review changes between the current branch and the given base branch.
|
||||
BaseBranch { branch: String },
|
||||
|
||||
/// Review the changes introduced by a specific commit.
|
||||
Commit {
|
||||
sha: String,
|
||||
/// Optional human-readable label (e.g., commit subject) for UIs.
|
||||
title: Option<String>,
|
||||
},
|
||||
|
||||
/// Arbitrary instructions, equivalent to the old free-form prompt.
|
||||
Custom { instructions: String },
|
||||
}
|
||||
|
||||
/// Validation errors for review targets.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ReviewTargetError {
|
||||
#[error("branch must not be empty")]
|
||||
EmptyBranch,
|
||||
#[error("sha must not be empty")]
|
||||
EmptySha,
|
||||
#[error("instructions must not be empty")]
|
||||
EmptyInstructions,
|
||||
}
|
||||
|
||||
/// Built review request plus a user-visible description of the target.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BuiltReviewRequest {
|
||||
pub review_request: ReviewRequest,
|
||||
pub display_text: String,
|
||||
}
|
||||
|
||||
/// Build the review prompt and user-facing hint for uncommitted changes.
|
||||
pub fn review_uncommitted_prompt() -> (String, String) {
|
||||
let prompt = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string();
|
||||
let hint = "current changes".to_string();
|
||||
(prompt, hint)
|
||||
}
|
||||
|
||||
/// Build the review prompt and hint for reviewing against a base branch.
|
||||
pub fn review_branch_prompt(branch: &str) -> (String, String) {
|
||||
let prompt = format!(
|
||||
"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{{upstream}}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings."
|
||||
);
|
||||
let hint = format!("changes against '{branch}'");
|
||||
(prompt, hint)
|
||||
}
|
||||
|
||||
/// Build the review prompt and hint for a specific commit.
|
||||
/// If `subject_opt` is provided, it will be included in the prompt; otherwise it is omitted.
|
||||
pub fn review_commit_prompt(sha: &str, subject_opt: Option<&str>) -> (String, String) {
|
||||
let short: String = sha.chars().take(min(7, sha.len())).collect();
|
||||
let prompt = if let Some(subject) = subject_opt {
|
||||
format!(
|
||||
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings."
|
||||
)
|
||||
};
|
||||
let hint = format!("commit {short}");
|
||||
(prompt, hint)
|
||||
}
|
||||
|
||||
/// Build the review prompt and hint for custom free-form instructions.
|
||||
pub fn review_custom_prompt(custom: &str) -> (String, String) {
|
||||
let prompt = custom.trim().to_string();
|
||||
(prompt.clone(), prompt)
|
||||
}
|
||||
|
||||
pub fn review_request_from_target(
|
||||
target: ReviewTarget,
|
||||
append_to_original_thread: bool,
|
||||
) -> Result<BuiltReviewRequest, ReviewTargetError> {
|
||||
match target {
|
||||
ReviewTarget::UncommittedChanges => {
|
||||
let (prompt, hint) = review_uncommitted_prompt();
|
||||
Ok(BuiltReviewRequest {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display_text: "Review uncommitted changes".to_string(),
|
||||
})
|
||||
}
|
||||
ReviewTarget::BaseBranch { branch } => {
|
||||
let branch = branch.trim().to_string();
|
||||
if branch.is_empty() {
|
||||
return Err(ReviewTargetError::EmptyBranch);
|
||||
}
|
||||
let (prompt, hint) = review_branch_prompt(&branch);
|
||||
Ok(BuiltReviewRequest {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display_text: format!("Review changes against base branch '{branch}'"),
|
||||
})
|
||||
}
|
||||
ReviewTarget::Commit { sha, title } => {
|
||||
let sha = sha.trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return Err(ReviewTargetError::EmptySha);
|
||||
}
|
||||
let title = title
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
let (prompt, hint) = review_commit_prompt(&sha, title.as_deref());
|
||||
let short_sha: String = sha.chars().take(min(7, sha.len())).collect();
|
||||
let display_text = if let Some(title) = title {
|
||||
format!("Review commit {short_sha}: {title}")
|
||||
} else {
|
||||
format!("Review commit {short_sha}")
|
||||
};
|
||||
Ok(BuiltReviewRequest {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display_text,
|
||||
})
|
||||
}
|
||||
ReviewTarget::Custom { instructions } => {
|
||||
let trimmed = instructions.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ReviewTargetError::EmptyInstructions);
|
||||
}
|
||||
let (prompt, hint) = review_custom_prompt(&trimmed);
|
||||
Ok(BuiltReviewRequest {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread,
|
||||
},
|
||||
display_text: trimmed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commit_target_includes_title_and_short_sha() {
|
||||
let built = review_request_from_target(
|
||||
ReviewTarget::Commit {
|
||||
sha: "123456789".to_string(),
|
||||
title: Some("Refactor colors".to_string()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.expect("valid commit target");
|
||||
|
||||
assert!(built.display_text.contains("1234567"));
|
||||
assert!(built.display_text.contains("Refactor colors"));
|
||||
assert_eq!(built.review_request.user_facing_hint, "commit 1234567");
|
||||
assert!(built.review_request.append_to_original_thread);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs_reject() {
|
||||
assert_eq!(
|
||||
review_request_from_target(
|
||||
ReviewTarget::BaseBranch {
|
||||
branch: " ".to_string()
|
||||
},
|
||||
false
|
||||
)
|
||||
.unwrap_err(),
|
||||
ReviewTargetError::EmptyBranch
|
||||
);
|
||||
assert_eq!(
|
||||
review_request_from_target(
|
||||
ReviewTarget::Commit {
|
||||
sha: "".to_string(),
|
||||
title: None
|
||||
},
|
||||
false
|
||||
)
|
||||
.unwrap_err(),
|
||||
ReviewTargetError::EmptySha
|
||||
);
|
||||
assert_eq!(
|
||||
review_request_from_target(
|
||||
ReviewTarget::Custom {
|
||||
instructions: "\n".to_string()
|
||||
},
|
||||
false
|
||||
)
|
||||
.unwrap_err(),
|
||||
ReviewTargetError::EmptyInstructions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Resume a previous session by id or pick the most recent with --last.
|
||||
Resume(ResumeArgs),
|
||||
|
||||
/// Run a code review.
|
||||
Review(ReviewArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -109,6 +112,33 @@ pub struct ResumeArgs {
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ReviewArgs {
|
||||
/// Select uncommitted (staged, unstaged, untracked) changes.
|
||||
#[arg(long = "uncommitted", default_value_t = false, conflicts_with_all = ["branch", "commit", "prompt"])]
|
||||
pub uncommitted: bool,
|
||||
|
||||
/// Review against the given base branch (PR-style).
|
||||
#[arg(long = "branch", value_name = "BRANCH", conflicts_with_all = ["commit", "uncommitted", "prompt"])]
|
||||
pub branch: Option<String>,
|
||||
|
||||
/// Review a specific commit by SHA.
|
||||
#[arg(long = "commit", value_name = "SHA", conflicts_with_all = ["branch", "uncommitted", "prompt"])]
|
||||
pub commit: Option<String>,
|
||||
|
||||
/// Optional override for the user-facing hint recorded in history.
|
||||
#[arg(long = "hint", value_name = "TEXT")]
|
||||
pub hint: Option<String>,
|
||||
|
||||
/// Optional override for the review model (defaults to config.review_model).
|
||||
#[arg(long = "review-model", value_name = "MODEL")]
|
||||
pub review_model: Option<String>,
|
||||
|
||||
/// Custom review instructions. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum Color {
|
||||
|
||||
@@ -10,12 +10,14 @@ use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
@@ -559,6 +561,31 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
ts_msg!(self, "task aborted: review ended");
|
||||
}
|
||||
},
|
||||
EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
user_facing_hint, ..
|
||||
}) => {
|
||||
let banner = format!(">> Code review started: {user_facing_hint} <<");
|
||||
ts_msg!(self, "{banner}");
|
||||
}
|
||||
EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }) => {
|
||||
if let Some(output) = review_output {
|
||||
if output.findings.is_empty() {
|
||||
let explanation = output.overall_explanation.trim();
|
||||
if !explanation.is_empty() {
|
||||
ts_msg!(self, "{explanation}");
|
||||
} else {
|
||||
ts_msg!(self, "Reviewer failed to output a response.");
|
||||
}
|
||||
} else {
|
||||
let block = codex_core::review_format::format_review_findings_block(
|
||||
&output.findings,
|
||||
None,
|
||||
);
|
||||
ts_msg!(self, "{block}");
|
||||
}
|
||||
}
|
||||
ts_msg!(self, "{}", "<< Code review finished >>".style(self.dimmed));
|
||||
}
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
@@ -569,8 +596,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::UserMessage(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
|
||||
@@ -143,6 +143,7 @@ impl EventProcessorWithJsonOutput {
|
||||
message: ev.message.clone(),
|
||||
})],
|
||||
EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev),
|
||||
EventMsg::ExitedReviewMode(ev) => self.handle_exited_review_mode(ev),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -184,6 +185,31 @@ impl EventProcessorWithJsonOutput {
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_exited_review_mode(
|
||||
&self,
|
||||
ev: &codex_core::protocol::ExitedReviewModeEvent,
|
||||
) -> Vec<ThreadEvent> {
|
||||
// Convert review output into a synthetic agent message so JSONL clients
|
||||
// can observe review results without bespoke item types for now.
|
||||
if let Some(output) = &ev.review_output {
|
||||
let text = if output.findings.is_empty() {
|
||||
output.overall_explanation.clone()
|
||||
} else {
|
||||
codex_core::review_format::format_review_findings_block(&output.findings, None)
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }),
|
||||
};
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_reasoning_event(&self, ev: &AgentReasoningEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
|
||||
@@ -30,6 +30,8 @@ use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::review_prompts::ReviewTarget;
|
||||
use codex_core::review_prompts::review_request_from_target;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -48,6 +50,7 @@ use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::cli::Command as ExecCommand;
|
||||
use crate::cli::ReviewArgs;
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use codex_core::default_client::set_default_originator;
|
||||
@@ -79,6 +82,28 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
// Helper: read a prompt from stdin when '-' was passed or when required.
|
||||
fn read_prompt_from_stdin_or_exit(force: bool) -> String {
|
||||
if !force && std::io::stdin().is_terminal() {
|
||||
eprintln!(
|
||||
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if !force {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
} else if buffer.trim().is_empty() {
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
|
||||
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
|
||||
let prompt_arg = match &command {
|
||||
// Allow prompt before the subcommand by falling back to the parent-level prompt
|
||||
@@ -96,30 +121,25 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
None
|
||||
}
|
||||
});
|
||||
resume_prompt.or(prompt)
|
||||
resume_prompt.or(prompt.clone())
|
||||
}
|
||||
None => prompt,
|
||||
Some(ExecCommand::Review(_)) => prompt.clone(),
|
||||
None => prompt.clone(),
|
||||
};
|
||||
|
||||
let prompt = match prompt_arg {
|
||||
Some(p) if p != "-" => p,
|
||||
// Either `-` was passed or no positional arg.
|
||||
maybe_dash => {
|
||||
// When no arg (None) **and** stdin is a TTY, bail out early – unless the
|
||||
// user explicitly forced reading via `-`.
|
||||
// Determine the regular prompt. If explicitly "-", read from stdin.
|
||||
// If omitted, read from stdin when non‑TTY; otherwise exit with a message.
|
||||
let regular_prompt_opt = match (&command, prompt_arg) {
|
||||
(Some(ExecCommand::Review(_)), _) => None,
|
||||
(_, Some(p)) if p != "-" => Some(p),
|
||||
(_, maybe_dash) => {
|
||||
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
|
||||
|
||||
if std::io::stdin().is_terminal() && !force_stdin {
|
||||
eprintln!(
|
||||
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Ensure the user knows we are waiting on stdin, as they may
|
||||
// have gotten into this state by mistake. If so, and they are not
|
||||
// writing to stdin, Codex will hang indefinitely, so this should
|
||||
// help them debug in that case.
|
||||
if !force_stdin {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
@@ -131,7 +151,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
buffer
|
||||
Some(buffer)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,9 +248,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
};
|
||||
|
||||
// Load configuration and determine approval policy
|
||||
// Allow --review-model override only when review subcommand is present.
|
||||
let review_model_override: Option<String> = match &command {
|
||||
Some(ExecCommand::Review(ReviewArgs { review_model, .. })) => review_model.clone(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
model,
|
||||
review_model: None,
|
||||
review_model: review_model_override,
|
||||
config_profile,
|
||||
// Default to never ask for approvals in headless mode. Feature flags can override.
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
@@ -329,8 +355,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
session_configured,
|
||||
} = if let Some(ExecCommand::Resume(args)) = command {
|
||||
let resume_path = resolve_resume_path(&config, &args).await?;
|
||||
} = if let Some(ExecCommand::Resume(args)) = &command {
|
||||
let resume_path = resolve_resume_path(&config, args).await?;
|
||||
|
||||
if let Some(path) = resume_path {
|
||||
conversation_manager
|
||||
@@ -346,9 +372,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
};
|
||||
// Print the effective configuration and prompt so users can see what Codex
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt, &session_configured);
|
||||
// Echo the effective configuration and the text that will be sent first.
|
||||
let mut echo_prompt = String::new();
|
||||
if let Some(p) = ®ular_prompt_opt {
|
||||
echo_prompt = p.clone();
|
||||
}
|
||||
event_processor.print_config_summary(&config, &echo_prompt, &session_configured);
|
||||
|
||||
info!("Codex initialized with event: {session_configured:?}");
|
||||
|
||||
@@ -391,25 +420,84 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
});
|
||||
}
|
||||
|
||||
// Package images and prompt into a single user input turn.
|
||||
let mut items: Vec<UserInput> = images
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text { text: prompt });
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
let _initial_prompt_task_id = match &command {
|
||||
Some(ExecCommand::Review(args)) => {
|
||||
let append_to_original_thread = true;
|
||||
let target = if let Some(custom) = args.prompt.as_deref() {
|
||||
let text = if custom == "-" {
|
||||
read_prompt_from_stdin_or_exit(true)
|
||||
} else {
|
||||
custom.to_string()
|
||||
};
|
||||
ReviewTarget::Custom { instructions: text }
|
||||
} else if let Some(branch) = &args.branch {
|
||||
ReviewTarget::BaseBranch {
|
||||
branch: branch.clone(),
|
||||
}
|
||||
} else if let Some(sha) = &args.commit {
|
||||
// Try to enrich with subject; fall back gracefully.
|
||||
let mut title: Option<String> = None;
|
||||
let entries = codex_core::git_info::recent_commits(&default_cwd, 200).await;
|
||||
for entry in entries {
|
||||
if entry.sha.starts_with(sha) {
|
||||
if !entry.subject.trim().is_empty() {
|
||||
title = Some(entry.subject);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ReviewTarget::Commit {
|
||||
sha: sha.clone(),
|
||||
title,
|
||||
}
|
||||
} else {
|
||||
// Default to reviewing uncommitted changes if nothing else specified.
|
||||
ReviewTarget::UncommittedChanges
|
||||
};
|
||||
|
||||
let mut built_request =
|
||||
review_request_from_target(target, append_to_original_thread)
|
||||
.map_err(|err| anyhow::anyhow!("invalid review target: {err}"))?;
|
||||
|
||||
if let Some(hint_override) = &args.hint {
|
||||
built_request.review_request.user_facing_hint = hint_override.clone();
|
||||
}
|
||||
|
||||
let id = conversation
|
||||
.submit(Op::Review {
|
||||
review_request: built_request.review_request,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent review request with event ID: {id}");
|
||||
id
|
||||
}
|
||||
_ => {
|
||||
// Package images and prompt into a single user input turn.
|
||||
let mut items: Vec<UserInput> = images
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
let text = match regular_prompt_opt {
|
||||
Some(t) => t,
|
||||
None => read_prompt_from_stdin_or_exit(false),
|
||||
};
|
||||
items.push(UserInput::Text { text });
|
||||
let id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {id}");
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
// Track whether a fatal error was reported by the server so we can
|
||||
|
||||
@@ -5,5 +5,6 @@ mod auth_env;
|
||||
mod originator;
|
||||
mod output_schema;
|
||||
mod resume;
|
||||
mod review;
|
||||
mod sandbox;
|
||||
mod server_error_exit;
|
||||
|
||||
59
codex-rs/exec/tests/suite/review.rs
Normal file
59
codex-rs/exec/tests/suite/review.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
use predicates::prelude::*;
|
||||
|
||||
/// Verify that `codex exec review` triggers the review flow and renders
|
||||
/// a formatted finding block when the reviewer returns a structured JSON result.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_review_uncommitted_renders_findings() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
|
||||
// Structured review output returned by the reviewer model (as a JSON string).
|
||||
let review_json = serde_json::json!({
|
||||
"findings": [
|
||||
{
|
||||
"title": "Prefer Stylize helpers",
|
||||
"body": "Use .dim()/.bold() chaining instead of manual Style where possible.",
|
||||
"confidence_score": 0.9,
|
||||
"priority": 1,
|
||||
"code_location": {
|
||||
"absolute_file_path": "/tmp/file.rs",
|
||||
"line_range": {"start": 10, "end": 20}
|
||||
}
|
||||
}
|
||||
],
|
||||
"overall_correctness": "good",
|
||||
"overall_explanation": "All good with some improvements suggested.",
|
||||
"overall_confidence_score": 0.8
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("m-1", &review_json),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, body).await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(test.cwd_path())
|
||||
.arg("review")
|
||||
.arg("--uncommitted")
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains(
|
||||
">> Code review started: current changes <<",
|
||||
))
|
||||
.stderr(predicate::str::contains(
|
||||
"- Prefer Stylize helpers — /tmp/file.rs:10-20",
|
||||
))
|
||||
.stderr(predicate::str::contains("<< Code review finished >>"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
2
codex-rs/tui/config.toml
Normal file
2
codex-rs/tui/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[projects."/Users/jif/code/codex"]
|
||||
trust_level = "untrusted"
|
||||
@@ -52,6 +52,7 @@ use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_core::review_prompts::ReviewTarget;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -144,6 +145,15 @@ const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini";
|
||||
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
|
||||
fn build_review_request(target: ReviewTarget) -> Option<ReviewRequest> {
|
||||
codex_core::review_prompts::review_request_from_target(target, true)
|
||||
.map(|built| built.review_request)
|
||||
.map_err(|err| {
|
||||
debug!(?err, "invalid review target");
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateLimitWarningState {
|
||||
secondary_index: usize,
|
||||
@@ -2808,19 +2818,14 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let uncommitted_request = build_review_request(ReviewTarget::UncommittedChanges);
|
||||
items.push(SelectionItem {
|
||||
name: "Review uncommitted changes".to_string(),
|
||||
actions: vec![Box::new(
|
||||
move |tx: &AppEventSender| {
|
||||
tx.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
append_to_original_thread: true,
|
||||
},
|
||||
}));
|
||||
},
|
||||
)],
|
||||
actions: vec![Box::new(move |tx: &AppEventSender| {
|
||||
if let Some(review_request) = uncommitted_request.clone() {
|
||||
tx.send(AppEvent::CodexOp(Op::Review { review_request }));
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -2867,15 +2872,11 @@ impl ChatWidget {
|
||||
items.push(SelectionItem {
|
||||
name: format!("{current_branch} -> {branch}"),
|
||||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: format!(
|
||||
"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{{upstream}}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings."
|
||||
),
|
||||
user_facing_hint: format!("changes against '{branch}'"),
|
||||
append_to_original_thread: true,
|
||||
},
|
||||
}));
|
||||
if let Some(review_request) = build_review_request(ReviewTarget::BaseBranch {
|
||||
branch: branch.clone(),
|
||||
}) {
|
||||
tx3.send(AppEvent::CodexOp(Op::Review { review_request }));
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(option),
|
||||
@@ -2900,23 +2901,17 @@ impl ChatWidget {
|
||||
for entry in commits {
|
||||
let subject = entry.subject.clone();
|
||||
let sha = entry.sha.clone();
|
||||
let short = sha.chars().take(7).collect::<String>();
|
||||
let search_val = format!("{subject} {sha}");
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: subject.clone(),
|
||||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||
let hint = format!("commit {short}");
|
||||
let prompt = format!(
|
||||
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
|
||||
);
|
||||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread: true,
|
||||
},
|
||||
}));
|
||||
if let Some(review_request) = build_review_request(ReviewTarget::Commit {
|
||||
sha: sha.clone(),
|
||||
title: Some(subject.clone()),
|
||||
}) {
|
||||
tx3.send(AppEvent::CodexOp(Op::Review { review_request }));
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(search_val),
|
||||
@@ -2945,13 +2940,11 @@ impl ChatWidget {
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
tx.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: trimmed.clone(),
|
||||
user_facing_hint: trimmed,
|
||||
append_to_original_thread: true,
|
||||
},
|
||||
}));
|
||||
if let Some(review_request) = build_review_request(ReviewTarget::Custom {
|
||||
instructions: trimmed,
|
||||
}) {
|
||||
tx.send(AppEvent::CodexOp(Op::Review { review_request }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
@@ -3151,23 +3144,17 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
for entry in entries {
|
||||
let subject = entry.subject.clone();
|
||||
let sha = entry.sha.clone();
|
||||
let short = sha.chars().take(7).collect::<String>();
|
||||
let search_val = format!("{subject} {sha}");
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: subject.clone(),
|
||||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||||
let hint = format!("commit {short}");
|
||||
let prompt = format!(
|
||||
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
|
||||
);
|
||||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt,
|
||||
user_facing_hint: hint,
|
||||
append_to_original_thread: true,
|
||||
},
|
||||
}));
|
||||
if let Some(review_request) = build_review_request(ReviewTarget::Commit {
|
||||
sha: sha.clone(),
|
||||
title: Some(subject.clone()),
|
||||
}) {
|
||||
tx3.send(AppEvent::CodexOp(Op::Review { review_request }));
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(search_val),
|
||||
|
||||
Reference in New Issue
Block a user