feat(exec): route slash commands and render review output

This commit is contained in:
James Peter
2025-11-30 17:19:28 +10:00
parent b13aae026c
commit 0737ce078b
4 changed files with 284 additions and 10 deletions
@@ -629,8 +629,64 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ConversationPath(_) => {}
EventMsg::UserMessage(_) => {}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
EventMsg::EnteredReviewMode(request) => {
ts_println!(
self,
"{} {}",
"review".style(self.magenta),
"started".style(self.bold),
);
if let Some(scope) = request
.metadata
.as_ref()
.and_then(|m| m.scope.as_ref())
{
println!("{} {}", "scope:".style(self.dimmed), scope.style(self.dimmed));
}
if !request.user_facing_hint.trim().is_empty() {
println!("{}", request.user_facing_hint.trim().style(self.dimmed));
}
}
EventMsg::ExitedReviewMode(output_opt) => {
ts_println!(
self,
"{} {}",
"review".style(self.magenta),
"finished".style(self.bold),
);
match output_opt {
Some(output) => {
if !output.overall_explanation.trim().is_empty() {
println!("{}", output.overall_explanation.trim());
}
if output.findings.is_empty() {
println!("{}", "no findings reported".style(self.dimmed));
} else {
for (idx, finding) in output.findings.iter().enumerate() {
println!(
"{} {}",
format!("#{}", idx + 1).style(self.bold),
finding.title.trim(),
);
if !finding.body.trim().is_empty() {
for line in finding.body.lines() {
println!(" {}", line.trim());
}
}
}
}
if output.overall_confidence_score > 0.0 {
println!(
"confidence: {:.1}",
output.overall_confidence_score,
);
}
}
None => println!("{}", "review ended without results".style(self.dimmed)),
}
}
EventMsg::CompactionCheckpointWarning(_) => {}
}
CodexStatus::Running
+44 -7
View File
@@ -2,6 +2,7 @@ mod cli;
mod event_processor;
mod event_processor_with_human_output;
mod event_processor_with_json_output;
mod slash;
pub use cli::Cli;
use code_auto_drive_core::start_auto_coordinator;
@@ -28,6 +29,7 @@ use code_core::protocol::Event;
use code_core::protocol::EventMsg;
use code_core::protocol::InputItem;
use code_core::protocol::Op;
use code_core::protocol::ReviewRequest;
use code_core::protocol::TaskCompleteEvent;
use code_protocol::models::ContentItem;
use code_protocol::models::ResponseItem;
@@ -52,6 +54,7 @@ use anyhow::Context;
use crate::cli::Command as ExecCommand;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::slash::{process_exec_slash_command, SlashContext, SlashDispatch};
use code_core::{entry_to_rollout_path, SessionCatalog, SessionQuery};
const AUTO_DRIVE_TEST_SUFFIX: &str = "After planning, but before you start, please ensure you can test the outcome of your changes. Test first to ensure it's failing, then again at the end to ensure it passes. Do not use work arounds or mock code to pass - solve the underlying issue. Create new tests as you work if needed. Once done, clean up your tests unless added to an existing test suite.";
@@ -157,11 +160,13 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option<PathBuf>) -> anyh
*goal = append_auto_drive_test_suffix(goal);
}
let summary_prompt = if let Some(goal) = auto_drive_goal.as_ref() {
let mut prompt_to_send = prompt.clone();
let mut summary_prompt = if let Some(goal) = auto_drive_goal.as_ref() {
format!("/auto {goal}")
} else {
prompt.clone()
};
let mut review_request: Option<ReviewRequest> = None;
let _output_schema = load_output_schema(output_schema_path);
@@ -248,6 +253,31 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option<PathBuf>) -> anyh
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
let slash_context = SlashContext {
agents: &config.agents,
subagent_commands: &config.subagent_commands,
};
match process_exec_slash_command(prompt_to_send.trim(), slash_context) {
Ok(SlashDispatch::NotSlash) => {}
Ok(SlashDispatch::ExpandedPrompt { prompt, summary }) => {
prompt_to_send = prompt;
if auto_drive_goal.is_none() {
summary_prompt = summary;
}
}
Ok(SlashDispatch::Review { request, summary }) => {
review_request = Some(request);
if auto_drive_goal.is_none() {
summary_prompt = summary;
}
}
Err(msg) => {
eprintln!("{msg}");
std::process::exit(1);
}
}
let stop_on_task_complete = auto_drive_goal.is_none();
let mut event_processor: Box<dyn EventProcessor> = if json_mode {
Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
@@ -472,12 +502,19 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option<PathBuf>) -> anyh
}
// Send the prompt.
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
// Fallback for older core protocol: send only user input items.
let initial_prompt_task_id = conversation
.submit(Op::UserInput { items })
.await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
let _initial_prompt_task_id = if let Some(review_request) = review_request {
let event_id = conversation.submit(Op::Review { review_request }).await?;
info!("Sent /review with event ID: {event_id}");
event_id
} else {
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt_to_send }];
// Fallback for older core protocol: send only user input items.
let event_id = conversation
.submit(Op::UserInput { items })
.await?;
info!("Sent prompt with event ID: {event_id}");
event_id
};
// Run the loop until the task is complete.
// Track whether a fatal error was reported by the server so we can
+182
View File
@@ -0,0 +1,182 @@
use code_core::config_types::{AgentConfig, SubagentCommandConfig};
use code_core::protocol::{ReviewContextMetadata, ReviewRequest};
use code_core::slash_commands::format_subagent_command;
#[derive(Clone, Copy)]
pub struct SlashContext<'a> {
pub agents: &'a [AgentConfig],
pub subagent_commands: &'a [SubagentCommandConfig],
}
pub enum SlashDispatch {
NotSlash,
ExpandedPrompt { prompt: String, summary: String },
Review { request: ReviewRequest, summary: String },
}
pub fn process_exec_slash_command(message: &str, ctx: SlashContext<'_>) -> Result<SlashDispatch, String> {
let trimmed = message.trim();
if trimmed.is_empty() {
return Ok(SlashDispatch::NotSlash);
}
if !trimmed.starts_with('/') {
return Ok(SlashDispatch::NotSlash);
}
let command_portion = trimmed.trim_start_matches('/');
let mut parts = command_portion.splitn(2, |c: char| c.is_whitespace());
let command_raw = parts.next().unwrap_or("");
let args_raw = parts.next().unwrap_or("").trim();
let command = command_raw.to_ascii_lowercase();
if command.is_empty() {
return Ok(SlashDispatch::NotSlash);
}
// Allow existing exec auto-drive handling to continue unchanged.
if command == "auto" {
return Ok(SlashDispatch::NotSlash);
}
match command.as_str() {
"plan" | "solve" | "code" => handle_subagent(command.as_str(), args_raw, ctx),
"review" => handle_review(args_raw),
other => {
// Custom subagents
if ctx
.subagent_commands
.iter()
.any(|c| c.name.eq_ignore_ascii_case(other))
{
return handle_subagent(other, args_raw, ctx);
}
Err(format!("Command '/{}' is not supported in exec mode.", other))
}
}
}
fn handle_subagent(
name: &str,
args: &str,
ctx: SlashContext<'_>,
) -> Result<SlashDispatch, String> {
if args.is_empty() {
return Err(format!(
"Error: /{name} requires a task description. Usage: /{name} <task>",
name = name
));
}
let formatted = format_subagent_command(name, args, Some(ctx.agents), Some(ctx.subagent_commands));
let summary_args = args.replace('\n', " ").trim().to_string();
let summary = if summary_args.is_empty() {
format!("/{name}")
} else {
format!("/{name} {summary_args}")
};
Ok(SlashDispatch::ExpandedPrompt {
prompt: formatted.prompt,
summary,
})
}
fn handle_review(args_raw: &str) -> Result<SlashDispatch, String> {
let (prompt, hint, metadata) = if args_raw.is_empty() {
(
"Review the current workspace changes and highlight bugs, regressions, risky patterns, and missing tests before merge.".to_string(),
"current workspace changes".to_string(),
ReviewContextMetadata {
scope: Some("workspace".to_string()),
..Default::default()
},
)
} else {
let text = args_raw.trim().to_string();
(
text.clone(),
text.clone(),
ReviewContextMetadata {
scope: Some("custom".to_string()),
..Default::default()
},
)
};
let summary = if args_raw.is_empty() {
"/review".to_string()
} else {
let hint_clean = hint.replace('\n', " ").trim().to_string();
if hint_clean.is_empty() {
"/review".to_string()
} else {
format!("/review {hint_clean}")
}
};
Ok(SlashDispatch::Review {
request: ReviewRequest {
prompt,
user_facing_hint: hint,
metadata: Some(metadata),
},
summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use code_core::config_types::{AgentConfig, SubagentCommandConfig};
fn ctx<'a>(agents: &'a [AgentConfig], subagents: &'a [SubagentCommandConfig]) -> SlashContext<'a> {
SlashContext { agents, subagent_commands: subagents }
}
#[test]
fn plan_expands_to_prompt() {
let result = process_exec_slash_command("/plan ship it", ctx(&[], &[])).unwrap();
match result {
SlashDispatch::ExpandedPrompt { prompt, summary } => {
assert!(prompt.contains("Task for /plan"));
assert_eq!(summary, "/plan ship it");
}
_ => panic!("expected expansion"),
}
}
#[test]
fn custom_subagent_is_supported() {
let subagent = SubagentCommandConfig { name: "audit".to_string(), ..Default::default() };
let result = process_exec_slash_command("/audit security pass", ctx(&[], &[subagent])).unwrap();
match result {
SlashDispatch::ExpandedPrompt { prompt, summary } => {
assert!(prompt.contains("Task for /audit"));
assert_eq!(summary, "/audit security pass");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn review_without_args_defaults_to_workspace_scope() {
let result = process_exec_slash_command("/review", ctx(&[], &[])).unwrap();
match result {
SlashDispatch::Review { request, summary } => {
assert_eq!(summary, "/review");
assert_eq!(request.user_facing_hint, "current workspace changes");
assert_eq!(request.metadata.unwrap().scope.unwrap(), "workspace");
}
_ => panic!("expected review"),
}
}
#[test]
fn unsupported_command_returns_error() {
let result = process_exec_slash_command("/theme", ctx(&[], &[]));
assert!(result.is_err());
}
}
-1
View File
@@ -3,7 +3,6 @@
// alternate‑screen mode starts; that file opts‑out locally via `allow`.
#![deny(clippy::print_stdout, clippy::print_stderr)]
#![deny(clippy::disallowed_methods)]
compile_error!("intentional failure for auto-review test");
use app::App;
use code_common::model_presets::{
all_model_presets,