merge code-branch-20251002-131120 via /merge

This commit is contained in:
James Peter
2025-10-03 09:47:25 +10:00
14 changed files with 808 additions and 143 deletions
+36
View File
@@ -1,9 +1,45 @@
use clap::Parser;
use codex_common::CliConfigOverrides;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum Command {
/// Submit a new task non-interactively and print the created id
Submit(SubmitArgs),
}
#[derive(Parser, Debug, Default, Clone)]
pub struct SubmitArgs {
/// The task prompt to submit to Codex Cloud
#[arg(value_name = "PROMPT")]
pub prompt: String,
/// Optional environment id (falls back to auto-detect when omitted)
#[arg(long = "env", value_name = "ENV_ID")]
pub env: Option<String>,
/// Best-of-N attempts for the assistant (default: 1)
#[arg(long = "best-of", default_value_t = 1)]
pub best_of: usize,
/// Enable QA/review mode when creating the task
#[arg(long = "qa", default_value_t = false)]
pub qa: bool,
/// Git ref to associate with the task (default: main)
#[arg(long = "git-ref", default_value = "main")]
pub git_ref: String,
/// Wait for completion and print final results
#[arg(long = "wait", default_value_t = false)]
pub wait: bool,
}
#[derive(Parser, Debug, Default)]
#[command(version)]
pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
#[clap(subcommand)]
pub cmd: Option<Command>,
}
+163 -1
View File
@@ -149,7 +149,12 @@ fn spawn_apply(
// (no standalone patch summarizer needed – UI displays raw diffs)
/// Entry point for the `codex cloud` subcommand.
pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
// Non-interactive submit mode: used by the core agent runner to create a
// cloud task and return its id on stdout.
if let Some(crate::cli::Command::Submit(args)) = cli.cmd {
return run_submit(args).await;
}
// Very minimal logging setup; mirrors other crates' pattern.
let default_level = "error";
let _ = tracing_subscriber::fmt()
@@ -1489,6 +1494,163 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
Ok(())
}
// Lightweight non-interactive submit implementation. Accepts a prompt and
// optional env/best-of/qa/git-ref and prints only the created task id.
async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> {
set_user_agent_suffix("codex_cloud_tasks_submit");
let use_mock = matches!(
std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(),
Some("mock") | Some("MOCK")
);
let backend: Arc<dyn codex_cloud_tasks_client::CloudBackend> = if use_mock {
Arc::new(codex_cloud_tasks_client::MockClient)
} else {
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
let ua = codex_core::default_client::get_codex_user_agent(None);
let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?
.with_user_agent(ua);
// Attach ChatGPT auth (required in production)
let _token = match codex_core::config::find_codex_home()
.ok()
.map(|home| {
codex_login::AuthManager::new(
home,
codex_login::AuthMode::ChatGPT,
codex_core::default_client::DEFAULT_ORIGINATOR.to_string(),
)
})
.and_then(|am| am.auth())
{
Some(auth) => match auth.get_token().await {
Ok(t) if !t.is_empty() => {
http = http.with_bearer_token(t.clone());
if let Some(acc) = auth
.get_account_id()
.or_else(|| util::extract_chatgpt_account_id(&t))
{
http = http.with_chatgpt_account_id(acc);
}
t
}
_ => {
eprintln!("Not signed in. Run 'codex login' and retry.");
std::process::exit(1);
}
},
None => {
eprintln!("Not signed in. Run 'codex login' and retry.");
std::process::exit(1);
}
};
Arc::new(http)
};
// Resolve target environment id
let env_id = if let Some(e) = args.env.clone() { e } else {
let base_url = util::normalize_base_url(
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
);
let headers = util::build_chatgpt_headers().await;
match crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await {
Ok(sel) => sel.id,
Err(_) => {
eprintln!("Failed to auto-detect environment. Provide --env <ENV_ID>.");
std::process::exit(1);
}
}
};
// Create the task
let created = codex_cloud_tasks_client::CloudBackend::create_task(
&*backend,
&env_id,
&args.prompt,
&args.git_ref,
args.qa,
args.best_of,
)
.await
.map_err(|e| anyhow::anyhow!("create_task failed: {e}"))?;
// If not waiting, print id and exit quickly
if !args.wait {
println!("{}", created.id.0);
return Ok(());
}
// Poll for completion and output a friendly summary when done
use tokio::time::{sleep, Duration};
eprintln!("Created task {}; waiting for completion…", created.id.0);
let task_id = created.id;
let mut seen_msgs = 0usize;
loop {
let text = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, task_id.clone())
.await
.unwrap_or_default();
// Emit progress hints to stderr so stdout remains the final result only
if text.messages.len() > seen_msgs {
let new = text.messages.len() - seen_msgs;
seen_msgs = text.messages.len();
eprintln!("progress: +{} message(s)", new);
}
use codex_cloud_tasks_client::AttemptStatus as S;
match text.attempt_status {
S::Completed => {
// Try to get a diff snapshot if available
let diff_opt = codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task_id.clone())
.await
.ok()
.flatten();
// Build final output as plain text for the agent result
let mut out = String::new();
out.push_str(&format!("Cloud task completed: {}\n\n", task_id.0));
if let Some(p) = text.prompt.as_deref() {
out.push_str(&format!("Prompt:\n{}\n\n", p.trim()));
}
if !text.messages.is_empty() {
out.push_str("Assistant Messages:\n");
// Safe formatting of assistant messages without NULs
for (i, m) in text.messages.iter().enumerate() {
out.push_str(&format!("{}. ", i + 1));
out.push_str(m.trim());
out.push_str("\n\n");
}
// (legacy NUL-containing formatter removed)
}
match diff_opt {
Some(diff) => {
out.push_str("Diff:\n");
out.push_str(&diff);
out.push_str("\n");
}
None => out.push_str("No diff available.\n"),
}
// Sanitize any embedded NULs that could corrupt downstream consumers.
let out = out.replace('\0', "");
print!("{}", out);
return Ok(());
}
S::Failed => {
anyhow::bail!("Task failed: {}", task_id.0);
}
S::Cancelled => {
anyhow::bail!("Task cancelled: {}", task_id.0);
}
_ => {
sleep(Duration::from_secs(3)).await;
}
}
}
}
// extract_chatgpt_account_id moved to util.rs
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
+13
View File
@@ -50,7 +50,20 @@ pub fn default_params_for(name: &str, read_only: bool) -> Vec<String> {
]
}
}
// Cloud agent: do not assume a prompt flag by default. Users can
// configure args via [[agents]]; we will append the prompt positionally.
"cloud" => Vec::new(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cloud_defaults_are_empty_both_modes() {
assert!(default_params_for("cloud", true).is_empty());
assert!(default_params_for("cloud", false).is_empty());
}
}
+151 -32
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::process::Command;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::RwLock;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
@@ -506,14 +507,18 @@ async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
drop(manager);
// Execute with full permissions in the worktree
execute_model_with_permissions(
&model,
&full_prompt,
false,
Some(worktree_path),
config.clone(),
)
.await
if model.to_ascii_lowercase() == "cloud" && config.is_none() {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, Some(worktree_path), config.clone()).await
} else {
execute_model_with_permissions(
&model,
&full_prompt,
false,
Some(worktree_path),
config.clone(),
)
.await
}
}
Err(e) => Err(format!("Failed to setup worktree: {}", e)),
}
@@ -526,7 +531,11 @@ async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
"{}\n\n[Running in read-only mode - no modifications allowed]",
full_prompt
);
execute_model_with_permissions(&model, &full_prompt, true, None, config).await
if model.to_ascii_lowercase() == "cloud" && config.is_none() {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, None, config).await
} else {
execute_model_with_permissions(&model, &full_prompt, true, None, config).await
}
};
// Update result
@@ -594,7 +603,7 @@ async fn execute_model_with_permissions(
// `codex` binary to be present on PATH. This improves portability,
// especially on Windows where global shims may be missing.
let model_lower = model.to_lowercase();
let mut cmd = if (model_lower == "code" || model_lower == "codex") && config.is_none() {
let mut cmd = if ((model_lower == "code" || model_lower == "codex") || model_lower == "cloud") && config.is_none() {
match std::env::current_exe() {
Ok(path) => Command::new(path),
Err(e) => return Err(format!("Failed to resolve current executable: {}", e)),
@@ -631,12 +640,25 @@ async fn execute_model_with_permissions(
}
// Build command based on model and permissions
// Use command instead of model for matching if config provided
let model_name = if config.is_some() { command.as_str() } else { model_lower.as_str() };
// Determine agent family for behavior (claude/gemini/qwen/code/codex/cloud).
// Prefer the model name; if it's not a known family, fall back to the configured
// command so aliases like command = "cloud-agent" still get cloud behavior.
let command_lower = command.to_ascii_lowercase();
fn is_known_family(s: &str) -> bool {
matches!(s, "claude" | "gemini" | "qwen" | "codex" | "code" | "cloud")
}
let family = if is_known_family(model_lower.as_str()) {
model_lower.as_str()
} else if is_known_family(command_lower.as_str()) {
command_lower.as_str()
} else {
model_lower.as_str()
};
match model_name {
let built_in_cloud = family == "cloud" && config.is_none();
match family {
"claude" | "gemini" | "qwen" => {
let mut defaults = crate::agent_defaults::default_params_for(model_name, read_only);
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push("-p".into());
defaults.push(prompt.to_string());
cmd.args(defaults);
@@ -647,21 +669,23 @@ async fn execute_model_with_permissions(
if have_mode_args {
cmd.arg(prompt);
} else {
let mut defaults = crate::agent_defaults::default_params_for(model_name, read_only);
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push(prompt.to_string());
cmd.args(defaults);
}
}
// Cloud agent: built-in path uses `code cloud submit <prompt>`; external
// command path falls back to positional prompt with optional defaults.
"cloud" => {
// The cloud agent CLI expects the prompt as a positional argument. If
// the configuration already provided explicit args for this mode, we
// append the prompt directly; otherwise we fall back to any shared
// defaults (if present) before adding the prompt.
let have_mode_args = config.as_ref().map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() }).unwrap_or(false);
if built_in_cloud { cmd.args(["cloud", "submit", "--wait"]); }
let have_mode_args = config
.as_ref()
.map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() })
.unwrap_or(false);
if have_mode_args {
cmd.arg(prompt);
} else {
let mut defaults = crate::agent_defaults::default_params_for(model_name, read_only);
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push(prompt.to_string());
cmd.args(defaults);
}
@@ -672,7 +696,9 @@ async fn execute_model_with_permissions(
// Proactively check for presence of external command before spawn when not
// using the current executable fallback. This avoids confusing OS errors
// like "program not found" and lets us surface a cleaner message.
if model_name != "codex" && model_name != "code" && !command_exists(&command) {
if !(family == "codex" || family == "code" || (family == "cloud" && config.is_none()))
&& !command_exists(&command)
{
return Err(format!("Required agent '{}' is not installed or not in PATH", command));
}
@@ -744,7 +770,7 @@ async fn execute_model_with_permissions(
// Intentionally build args fresh for sandbox helpers; `Command` does not expose argv.
// Rebuild the invocation as `command` + args set above.
// We reconstruct to run under our sandbox helpers.
let program = if (model_lower == "code" || model_lower == "codex") && config.is_none() {
let program = if ((model_lower == "code" || model_lower == "codex") || model_lower == "cloud") && config.is_none() {
// Use current exe path
std::env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?
} else {
@@ -770,9 +796,10 @@ async fn execute_model_with_permissions(
}
}
match model_name {
let built_in_cloud = family == "cloud" && config.is_none();
match family {
"claude" | "gemini" | "qwen" => {
let mut defaults = crate::agent_defaults::default_params_for(model_name, read_only);
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push("-p".into());
defaults.push(prompt.to_string());
args.extend(defaults);
@@ -782,7 +809,21 @@ async fn execute_model_with_permissions(
if have_mode_args {
args.push(prompt.to_string());
} else {
let mut defaults = crate::agent_defaults::default_params_for(model_name, read_only);
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push(prompt.to_string());
args.extend(defaults);
}
}
"cloud" => {
if built_in_cloud { args.extend(["cloud", "submit", "--wait"].map(String::from)); }
let have_mode_args = config
.as_ref()
.map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() })
.unwrap_or(false);
if have_mode_args {
args.push(prompt.to_string());
} else {
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
defaults.push(prompt.to_string());
args.extend(defaults);
}
@@ -799,7 +840,7 @@ async fn execute_model_with_permissions(
crate::spawn::spawn_child_async(
program.clone(),
args.clone(),
Some(&program.to_string_lossy()),
Some(program.to_string_lossy().as_ref()),
working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
@@ -830,7 +871,7 @@ async fn execute_model_with_permissions(
Ok(o) => o,
Err(e) => {
// Only fall back for external CLIs (not the built-in code/codex path)
if model_name == "codex" || model_name == "code" {
if family == "codex" || family == "code" {
return Err(format!("Failed to execute {}: {}", model, e));
}
let mut fb = match std::env::current_exe() {
@@ -871,6 +912,85 @@ async fn execute_model_with_permissions(
}
}
/// Execute the built-in cloud agent via the current `code` binary, streaming
/// stderr lines into the HUD as progress and returning final stdout. Applies a
/// modest truncation cap to very large outputs to keep UI responsive.
async fn execute_cloud_built_in_streaming(
agent_id: &str,
prompt: &str,
working_dir: Option<std::path::PathBuf>,
_config: Option<AgentConfig>,
) -> Result<String, String> {
// Program and argv
let program = std::env::current_exe()
.map_err(|e| format!("Failed to resolve current executable: {}", e))?;
let args: Vec<String> = vec![
"cloud".into(),
"submit".into(),
"--wait".into(),
prompt.into(),
];
// Baseline env mirrors behavior in execute_model_with_permissions
let env: std::collections::HashMap<String, String> = std::env::vars().collect();
use crate::protocol::SandboxPolicy;
use crate::spawn::StdioPolicy;
let mut child = crate::spawn::spawn_child_async(
program.clone(),
args.clone(),
Some(program.to_string_lossy().as_ref()),
working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
env,
)
.await
.map_err(|e| format!("Failed to spawn cloud submit: {}", e))?;
// Stream stderr to HUD
let stderr_task = if let Some(stderr) = child.stderr.take() {
let agent = agent_id.to_string();
Some(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg = line.trim();
if msg.is_empty() { continue; }
let mut mgr = AGENT_MANAGER.write().await;
mgr.add_progress(&agent, msg.to_string()).await;
}
}))
} else { None };
// Collect stdout fully (final result)
let mut stdout_buf = String::new();
if let Some(stdout) = child.stdout.take() {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
stdout_buf.push_str(&line);
stdout_buf.push('\n');
}
}
let status = child.wait().await.map_err(|e| format!("Failed to wait: {}", e))?;
if let Some(t) = stderr_task { let _ = t.await; }
if !status.success() {
return Err(format!("cloud submit exited with status {}", status));
}
// Truncate large outputs
const MAX_BYTES: usize = 500_000; // ~500 KB
if stdout_buf.len() > MAX_BYTES {
let omitted = stdout_buf.len() - MAX_BYTES;
let mut truncated = String::with_capacity(MAX_BYTES + 128);
truncated.push_str(&stdout_buf[..MAX_BYTES]);
truncated.push_str(&format!("\n… [truncated: {} bytes omitted]", omitted));
Ok(truncated)
} else {
Ok(stdout_buf)
}
}
// Tool creation functions
pub fn create_run_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
@@ -887,7 +1007,7 @@ pub fn create_run_agent_tool() -> OpenAiTool {
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Optional: Array of model names (e.g., ['claude','gemini','qwen','code'])".to_string(),
"Optional: Array of model names (e.g., ['claude','gemini','qwen','code','cloud'])".to_string(),
),
},
);
@@ -971,7 +1091,7 @@ pub fn create_get_agent_result_tool() -> OpenAiTool {
OpenAiTool::Function(ResponsesApiTool {
name: "agent_result".to_string(),
description: "Get the final result of a completed agent.".to_string(),
description: "Get the final result of a completed agent. Returns a preview (first 500 lines) and a file path to the full output/error.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
@@ -1056,8 +1176,7 @@ pub fn create_wait_for_agent_tool() -> OpenAiTool {
OpenAiTool::Function(ResponsesApiTool {
name: "agent_wait".to_string(),
description: "Wait for a agent or any agent in a batch to complete, fail, or be cancelled."
.to_string(),
description: "Wait for an agent or any agent in a batch to complete, fail, or be cancelled. Returns status plus a preview (first 500 lines) and a file path to the full output/error. For batches, set return_all:true to receive per-agent summaries. Use agent_result with {\"agent_id\": \"...\"} to fetch after completion.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
+147 -32
View File
@@ -2468,6 +2468,10 @@ impl Session {
self.get_pending_input_filtered(true)
}
/// Returns pending input for the current turn. Callers can decide whether
/// queued user inputs should be drained immediately (`drain_user_inputs = true`)
/// or preserved for a later turn—for example, review mode keeps them queued
/// so the primary agent can resume once the review finishes.
pub fn get_pending_input_filtered(&self, drain_user_inputs: bool) -> Vec<ResponseInputItem> {
let mut state = self.state.lock().unwrap();
if state.pending_input.is_empty()
@@ -2985,6 +2989,34 @@ async fn submission_loop(
let effective_user_instructions = computed_user_instructions.clone();
// Optionally resume an existing rollout.
let mut restored_items: Option<Vec<RolloutItem>> = None;
let mut restored_history_snapshot: Option<crate::history::HistorySnapshot> = None;
let mut resume_notice: Option<String> = None;
let mut rollout_recorder: Option<RolloutRecorder> = None;
if let Some(path) = resume_path.as_ref() {
match RolloutRecorder::resume(&updated_config, path).await {
Ok((rec, saved)) => {
session_id = saved.session_id;
if !saved.items.is_empty() {
restored_items = Some(saved.items);
}
if let Some(snapshot) = saved.history_snapshot {
restored_history_snapshot = Some(snapshot);
}
rollout_recorder = Some(rec);
}
Err(e) => {
warn!("failed to resume rollout from {path:?}: {e}");
resume_notice = Some(format!(
"⚠️ Failed to load previous session from {}: {e}. Starting a new conversation instead.",
path.display()
));
updated_config.experimental_resume = None;
}
}
}
let new_config = Arc::new(updated_config);
if model_changed || effort_changed {
@@ -3002,31 +3034,6 @@ async fn submission_loop(
config = Arc::clone(&new_config);
// Optionally resume an existing rollout.
let mut restored_items: Option<Vec<RolloutItem>> = None;
let mut restored_history_snapshot: Option<crate::history::HistorySnapshot> = None;
let rollout_recorder: Option<RolloutRecorder> =
if let Some(path) = resume_path.as_ref() {
match RolloutRecorder::resume(&config, path).await {
Ok((rec, saved)) => {
session_id = saved.session_id;
if !saved.items.is_empty() {
restored_items = Some(saved.items);
}
if let Some(snapshot) = saved.history_snapshot {
restored_history_snapshot = Some(snapshot);
}
Some(rec)
}
Err(e) => {
warn!("failed to resume rollout from {path:?}: {e}");
None
}
}
} else {
None
};
let rollout_recorder = match rollout_recorder {
Some(rec) => Some(rec),
None => {
@@ -3287,6 +3294,16 @@ async fn submission_loop(
}
}
if let Some(notice) = resume_notice {
let event = sess_arc.make_event(
&sub.id,
EventMsg::BackgroundEvent(BackgroundEventEvent { message: notice }),
);
if let Err(e) = tx_event.send(event).await {
warn!("failed to send resume notice event: {e}");
}
}
if let Some(sess_arc) = &sess {
sess_arc.run_session_hooks(ProjectHookEvent::SessionStart).await;
}
@@ -3876,11 +3893,13 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
// Note that pending_input would be something like a message the user
// submitted through the UI while the model was running. Though the UI
// may support this, the model might not.
let pending_input = if is_review_mode {
sess.get_pending_input_filtered(false)
} else {
sess.get_pending_input()
}
// IMPORTANT: Do not inject queued user inputs into the review thread.
// Doing so routes user messages (e.g., auto-resolve fix prompts) to the
// review model, causing loops. Only include queued user inputs when not in
// review mode. They will be picked up after TaskComplete via
// pop_next_queued_user_input.
let pending_input = sess
.get_pending_input_filtered(!is_review_mode)
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<ResponseItem>>();
@@ -6509,6 +6528,8 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
"claude" => ("claude".to_string(), false),
"gemini" => ("gemini".to_string(), false),
"qwen" => ("qwen".to_string(), false),
// Cloud agent: treat as built-in via current executable (code cloud submit)
"cloud" => ("cloud".to_string(), true),
_ => (m, false),
}
}
@@ -7030,8 +7051,21 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled
) {
// Include output/error preview and file path
// Avoid holding manager lock during filesystem I/O
drop(manager);
let cwd = sess.get_cwd().to_path_buf();
let dir = ensure_agent_dir(&cwd, &agent.id).unwrap_or_else(|_| cwd.clone());
let dir = match ensure_agent_dir(&cwd, &agent.id) {
Ok(d) => d,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to prepare agent output dir: {}", e),
success: Some(false),
},
};
}
};
let (preview_key, file_key, preview, file_path, total_lines) = match agent.status {
AgentStatus::Completed => {
let text = agent.result.clone().unwrap_or_default();
@@ -7060,11 +7094,14 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
_ => unreachable!(),
};
let hint = format!("agent_result {{\"agent_id\":\"{}\"}}", agent.id);
let mut response = serde_json::json!({
"agent_id": agent.id,
"status": agent.status,
"wait_time_seconds": start.elapsed().as_secs(),
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": agent.id },
});
if let Some(obj) = response.as_object_mut() {
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
@@ -7102,9 +7139,71 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
if params.return_all.unwrap_or(false) {
// Wait for ALL agents in the batch to reach a terminal state
if !any_in_progress {
// Enriched response: include per-agent previews and file paths
// Avoid holding manager lock during filesystem I/O
drop(manager);
let cwd = sess.get_cwd().to_path_buf();
let mut summaries: Vec<serde_json::Value> = Vec::new();
for a in &completed_agents {
let dir = match ensure_agent_dir(&cwd, &a.id) {
Ok(d) => d,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to prepare agent output dir: {}", e),
success: Some(false),
},
};
}
};
let (preview_key, file_key, preview, file_path, total_lines) = match a.status {
AgentStatus::Completed => {
let text = a.result.clone().unwrap_or_default();
let (p, total) = preview_first_n_lines(&text, 500);
let fp = write_agent_file(&dir, "result.txt", &text)
.map(|p| p.display().to_string())
.unwrap_or_else(|e| format!("Failed to write result file: {}", e));
("output_preview", "output_file", p, fp, total)
}
AgentStatus::Failed => {
let text = a.error.clone().unwrap_or_else(|| "Unknown error".to_string());
let (p, total) = preview_first_n_lines(&text, 500);
let fp = write_agent_file(&dir, "error.txt", &text)
.map(|p| p.display().to_string())
.unwrap_or_else(|e| format!("Failed to write error file: {}", e));
("error_preview", "error_file", p, fp, total)
}
AgentStatus::Cancelled => {
let text = "Agent cancelled".to_string();
let (p, total) = preview_first_n_lines(&text, 500);
let fp = write_agent_file(&dir, "status.txt", &text)
.map(|p| p.display().to_string())
.unwrap_or_else(|e| format!("Failed to write status file: {}", e));
("status_preview", "status_file", p, fp, total)
}
_ => unreachable!(),
};
let hint = format!("agent_result {{\"agent_id\":\"{}\"}}", a.id);
let mut obj = serde_json::json!({
"agent_id": a.id,
"status": a.status,
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": a.id },
});
if let Some(map) = obj.as_object_mut() {
map.insert(preview_key.to_string(), serde_json::Value::String(preview));
map.insert(file_key.to_string(), serde_json::Value::String(file_path));
}
summaries.push(obj);
}
let response = serde_json::json!({
"batch_id": batch_id,
"completed_agents": completed_agents.iter().map(|t| t.id.clone()).collect::<Vec<_>>(),
"completed_summaries": summaries,
"wait_time_seconds": start.elapsed().as_secs(),
});
return ResponseInputItem::FunctionCallOutput {
@@ -7134,8 +7233,21 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
drop(state);
// Include output/error preview for the unseen completed agent
// Avoid holding manager lock during filesystem I/O
drop(manager);
let cwd = sess.get_cwd().to_path_buf();
let dir = ensure_agent_dir(&cwd, &unseen.id).unwrap_or_else(|_| cwd.clone());
let dir = match ensure_agent_dir(&cwd, &unseen.id) {
Ok(d) => d,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to prepare agent output dir: {}", e),
success: Some(false),
},
};
}
};
let (preview_key, file_key, preview, file_path, total_lines) = match unseen.status {
AgentStatus::Completed => {
let text = unseen.result.clone().unwrap_or_default();
@@ -7164,11 +7276,14 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
_ => unreachable!(),
};
let hint = format!(r#"agent_result {{"agent_id":"{}"}}"#, unseen.id);
let mut response = serde_json::json!({
"agent_id": unseen.id,
"status": unseen.status,
"wait_time_seconds": start.elapsed().as_secs(),
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": unseen.id },
});
if let Some(obj) = response.as_object_mut() {
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
+5 -1
View File
@@ -22,6 +22,7 @@ fn get_default_models() -> Vec<String> {
"gemini".to_string(),
"qwen".to_string(),
"code".to_string(),
"cloud".to_string(),
]
}
@@ -238,7 +239,10 @@ mod tests {
// Test /plan command
let result = handle_slash_command("/plan implement a new feature", None);
assert!(result.is_some());
assert!(result.unwrap().contains("Create a comprehensive plan"));
let plan_prompt = result.unwrap();
assert!(plan_prompt.contains("Create a comprehensive plan"));
// Default agents list should include cloud when no [[agents]] configured
assert!(plan_prompt.to_ascii_lowercase().contains("cloud"));
// Test /solve command
let result = handle_slash_command("/solve fix the bug in authentication", None);
+94 -43
View File
@@ -1338,6 +1338,12 @@ enum SystemPlacement {
PrePromptInCurrent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AutoDriveRole {
User,
Assistant,
}
impl ChatWidget<'_> {
const MAX_UNDO_CONVERSATION_MESSAGES: usize = 8;
const MAX_UNDO_PREVIEW_CHARS: usize = 160;
@@ -3716,6 +3722,30 @@ impl ChatWidget<'_> {
rows.join("\n")
}
fn auto_drive_role_for_kind(kind: HistoryCellType) -> Option<AutoDriveRole> {
use AutoDriveRole::{Assistant, User};
match kind {
HistoryCellType::User => Some(Assistant),
HistoryCellType::Assistant
| HistoryCellType::Reasoning
| HistoryCellType::Error
| HistoryCellType::Exec { .. }
| HistoryCellType::Patch { .. }
| HistoryCellType::PlanUpdate
| HistoryCellType::BackgroundEvent
| HistoryCellType::Notice
| HistoryCellType::Diff
| HistoryCellType::Plain
| HistoryCellType::Image => Some(User),
HistoryCellType::Tool { status } => match status {
crate::history_cell::ToolCellStatus::Running => None,
crate::history_cell::ToolCellStatus::Success
| crate::history_cell::ToolCellStatus::Failed => Some(User),
},
HistoryCellType::AnimatedWelcome | HistoryCellType::Loading => None,
}
}
fn auto_drive_cell_text(cell: &dyn HistoryCell) -> Option<String> {
let text = Self::auto_drive_lines_to_string(cell.display_lines());
if text.trim().is_empty() {
@@ -3831,31 +3861,12 @@ impl ChatWidget<'_> {
pub(crate) fn export_auto_drive_items(&self) -> Vec<codex_protocol::models::ResponseItem> {
let mut items = Vec::new();
for cell in &self.history_cells {
match cell.kind() {
HistoryCellType::User => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
if let Some(item) = Self::auto_drive_make_assistant_message(text) {
items.push(item);
}
}
}
HistoryCellType::Assistant => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
if let Some(item) = Self::auto_drive_make_user_message(text) {
items.push(item);
}
}
}
HistoryCellType::Reasoning => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
use codex_protocol::models::{ContentItem, ResponseItem};
items.push(ResponseItem::Message {
id: Some("auto-drive-reasoning".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
}
}
let Some(role) = Self::auto_drive_role_for_kind(cell.kind()) else {
continue;
};
let text = match cell.kind() {
HistoryCellType::Reasoning => Self::auto_drive_cell_text(cell.as_ref()).map(|text| (text, true)),
HistoryCellType::PlanUpdate => {
if let Some(plan) = cell.as_any().downcast_ref::<PlanUpdateCell>() {
let state = plan.state();
@@ -3884,22 +3895,45 @@ impl ChatWidget<'_> {
}
}
let text = lines.join("\n");
if let Some(item) = Self::auto_drive_make_user_message(text) {
items.push(item);
}
Some((text, false))
} else {
Self::auto_drive_cell_text(cell.as_ref()).map(|text| (text, false))
}
}
HistoryCellType::Diff => {
if let Some(diff_cell) = cell.as_any().downcast_ref::<DiffCell>() {
if let Some(summary) = Self::auto_drive_diff_summary(diff_cell.record()) {
if let Some(item) = Self::auto_drive_make_user_message(summary) {
items.push(item);
}
}
Self::auto_drive_diff_summary(diff_cell.record()).map(|text| (text, false))
} else {
Self::auto_drive_cell_text(cell.as_ref()).map(|text| (text, false))
}
}
_ => {}
}
_ => Self::auto_drive_cell_text(cell.as_ref()).map(|text| (text, false)),
};
let Some((text, is_reasoning)) = text else {
continue;
};
let item = if is_reasoning {
codex_protocol::models::ResponseItem::Message {
id: Some("auto-drive-reasoning".to_string()),
role: "user".to_string(),
content: vec![codex_protocol::models::ContentItem::InputText { text }],
}
} else {
match role {
AutoDriveRole::Assistant => match Self::auto_drive_make_assistant_message(text) {
Some(item) => item,
None => continue,
},
AutoDriveRole::User => match Self::auto_drive_make_user_message(text) {
Some(item) => item,
None => continue,
},
}
};
items.push(item);
}
items
}
@@ -8294,7 +8328,12 @@ impl ChatWidget<'_> {
// the status spinner can hide promptly when nothing else is running.
self.active_task_ids.remove(&id);
self.maybe_hide_spinner();
self.auto_on_assistant_final();
// Important: do not advance Auto Drive here. The StreamController will emit
// AppEvent::InsertFinalAnswer, and the App thread will finalize the assistant
// cell slightly later. Advancing at this point can start the next Auto Drive
// step before the final answer is actually inserted, which appears as a
// mid-turn re-trigger. We instead advance immediately after insertion inside
// insert_final_answer_with_id().
}
EventMsg::ReplayHistory(ev) => {
self.clear_resume_placeholder();
@@ -9524,6 +9563,7 @@ impl ChatWidget<'_> {
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
info!("BackgroundEvent: {message}");
self.clear_resume_placeholder();
// Route through unified system notice helper. If the core ties the
// event to a turn (order present), prefer placing it before the next
// provider output; else append to the tail. Use the event.id for
@@ -14729,6 +14769,8 @@ impl ChatWidget<'_> {
}
self.last_assistant_message = Some(final_source.clone());
let _ = self.finalize_answer_stream_state(id.as_deref(), &final_source);
// Advance Auto Drive after the assistant message has been finalized.
self.auto_on_assistant_final();
return;
}
// Debug: list last few history cell kinds so we can see what's present
@@ -14857,6 +14899,8 @@ impl ChatWidget<'_> {
.insert(StreamId(want.clone()));
}
self.autoscroll_if_near_bottom();
// Final cell committed via replacement; now advance Auto Drive.
self.auto_on_assistant_final();
return;
}
@@ -14886,6 +14930,8 @@ impl ChatWidget<'_> {
.closed_answer_ids
.insert(StreamId(want.clone()));
self.autoscroll_if_near_bottom();
// Final cell replaced in-place; advance Auto Drive now.
self.auto_on_assistant_final();
return;
}
}
@@ -14925,13 +14971,15 @@ impl ChatWidget<'_> {
tracing::debug!(
"final-answer: replacing tail AssistantMarkdownCell via heuristic identical/superset"
);
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
self.autoscroll_if_near_bottom();
return;
}
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
self.autoscroll_if_near_bottom();
// Final assistant content revised; advance Auto Drive now.
self.auto_on_assistant_final();
return;
}
}
// Fallback: no prior assistant cell found; insert at stable sequence position.
@@ -14967,6 +15015,9 @@ impl ChatWidget<'_> {
.closed_answer_ids
.insert(StreamId(want.clone()));
}
// Ordered insert completed; advance Auto Drive now that the assistant
// message is present in history.
self.auto_on_assistant_final();
}
// Assign or fetch a stable sequence for a stream kind+id within its originating turn
+126 -25
View File
@@ -1,4 +1,6 @@
use codex_protocol::models::ResponseItem;
use std::collections::VecDeque;
use codex_protocol::models::{ContentItem, ResponseItem};
/// Maintains the Auto Drive conversation transcript between coordinator turns.
///
@@ -9,7 +11,7 @@ use codex_protocol::models::ResponseItem;
pub(crate) struct AutoDriveHistory {
converted: Vec<ResponseItem>,
raw: Vec<ResponseItem>,
pending_skip: usize,
pending_duplicates: VecDeque<NormalizedMessage>,
}
impl AutoDriveHistory {
@@ -17,7 +19,7 @@ impl AutoDriveHistory {
Self {
converted: Vec::new(),
raw: Vec::new(),
pending_skip: 0,
pending_duplicates: VecDeque::new(),
}
}
@@ -26,7 +28,7 @@ impl AutoDriveHistory {
pub(crate) fn replace_converted(&mut self, items: Vec<ResponseItem>) -> Vec<ResponseItem> {
let prev_len = self.converted.len();
self.converted = items;
let mut tail: Vec<_> = if self.converted.len() <= prev_len {
let tail: Vec<_> = if self.converted.len() <= prev_len {
Vec::new()
} else {
self.converted
@@ -36,17 +38,35 @@ impl AutoDriveHistory {
.collect()
};
if !tail.is_empty() && self.pending_skip > 0 {
if self.pending_skip >= tail.len() {
self.pending_skip -= tail.len();
tail.clear();
} else {
tail.drain(0..self.pending_skip);
self.pending_skip = 0;
}
if tail.is_empty() {
return tail;
}
tail
if self.pending_duplicates.is_empty() {
return tail;
}
let mut filtered = Vec::with_capacity(tail.len());
let queue = &mut self.pending_duplicates;
for item in tail.into_iter() {
let matched = normalize_message(&item)
.and_then(|message| queue.front().map(|expected| (message, expected)))
.map(|(message, expected)| message == *expected)
.unwrap_or(false);
if matched {
queue.pop_front();
continue;
}
if queue.front().is_some() {
queue.clear();
}
filtered.push(item);
}
filtered
}
pub(crate) fn append_raw(&mut self, items: &[ResponseItem]) {
@@ -54,13 +74,11 @@ impl AutoDriveHistory {
return;
}
self.raw.extend(items.iter().cloned());
let convertible_count = items
.iter()
.filter(|item| matches!(item, ResponseItem::Message { .. }))
.count();
self.pending_skip = self
.pending_skip
.saturating_add(convertible_count);
for item in items.iter() {
if let Some(message) = normalize_message(item) {
self.pending_duplicates.push_back(message);
}
}
}
pub(crate) fn append_converted_tail(&mut self, items: &[ResponseItem]) {
@@ -77,7 +95,7 @@ impl AutoDriveHistory {
pub(crate) fn clear(&mut self) {
self.converted.clear();
self.raw.clear();
self.pending_skip = 0;
self.pending_duplicates.clear();
}
pub(crate) fn converted_is_empty(&self) -> bool {
@@ -115,18 +133,18 @@ mod tests {
assert_eq!(tail.len(), 1);
history.append_converted_tail(&tail);
assert_eq!(history.raw_snapshot(), first);
assert_eq!(history.pending_skip, 0);
assert!(history.pending_duplicates.is_empty());
// Coordinator delivers transcript; these entries should be skipped on the next rebuild.
let transcript = vec![text_message("assistant", "ack")];
history.append_raw(&transcript);
assert_eq!(history.pending_skip, transcript.len());
assert_eq!(history.pending_duplicates.len(), 1);
// UI rebuild includes the coordinator output; nothing new should be appended and skip resets.
let second = vec![text_message("user", "hi"), text_message("assistant", "ack")];
let tail = history.replace_converted(second.clone());
assert!(tail.is_empty());
assert_eq!(history.pending_skip, 0);
assert!(history.pending_duplicates.is_empty());
// A new user turn appears; ensure it is not dropped by the skip logic.
let third = vec![
@@ -140,6 +158,89 @@ mod tests {
let snapshot = history.raw_snapshot();
assert_eq!(snapshot, third);
assert_eq!(history.pending_skip, 0);
assert!(history.pending_duplicates.is_empty());
}
#[test]
fn skips_only_matching_duplicates() {
let mut history = AutoDriveHistory::new();
// Coordinator appends an auto-generated assistant message.
let transcript = vec![ResponseItem::Message {
id: Some("msg-123".to_string()),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "auto step".to_string(),
}],
}];
history.append_raw(&transcript);
assert_eq!(history.pending_duplicates.len(), 1);
// UI rebuild produces an unrelated tail entry; it should not be dropped.
let conversation = vec![
text_message("user", "hi"),
text_message("assistant", "auto step"),
text_message("assistant", "manual edit"),
];
let tail = history.replace_converted(conversation.clone());
assert_eq!(tail.len(), 3);
assert!(history.pending_duplicates.is_empty());
}
#[test]
fn dedupe_ignores_ids() {
let mut history = AutoDriveHistory::new();
let transcript = vec![ResponseItem::Message {
id: Some("msg-xyz".to_string()),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "coordinator turn".to_string(),
}],
}];
history.append_raw(&transcript);
assert_eq!(history.pending_duplicates.len(), 1);
let rebuilt = vec![
text_message("user", "hi"),
text_message("assistant", "coordinator turn"),
];
let tail = history.replace_converted(rebuilt);
assert!(tail.is_empty(), "turn should be skipped despite differing ids");
assert!(history.pending_duplicates.is_empty());
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct NormalizedMessage {
role: String,
content: Vec<NormalizedContent>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum NormalizedContent {
InputText(String),
OutputText(String),
InputImage(String),
}
fn normalize_message(item: &ResponseItem) -> Option<NormalizedMessage> {
if let ResponseItem::Message { role, content, .. } = item {
let normalized = content
.iter()
.map(|chunk| match chunk {
ContentItem::InputText { text } => NormalizedContent::InputText(text.clone()),
ContentItem::OutputText { text } => NormalizedContent::OutputText(text.clone()),
ContentItem::InputImage { image_url } => {
NormalizedContent::InputImage(image_url.clone())
}
})
.collect();
Some(NormalizedMessage {
role: role.clone(),
content: normalized,
})
} else {
None
}
}
+29
View File
@@ -11,12 +11,17 @@ use crate::app_event::{
};
use crate::app_event_sender::AppEventSender;
use crate::history::state::{
ArgumentValue,
ExecStatus,
ExploreEntryStatus,
HistoryDomainRecord,
HistoryId,
HistoryRecord,
HistoryState,
ToolArgument,
ToolCallState,
ToolResultPreview,
ToolStatus,
};
use crate::slash_command::SlashCommand;
use super::auto_coordinator::AutoCoordinatorHandle;
@@ -3735,6 +3740,24 @@ fn export_auto_drive_items_includes_cli_outputs() {
let diff = "diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1 +1,2 @@\n-println!(\"old\");\n+println!(\"new\");\n+println!(\"extra\");\n";
chat.add_diff_output(diff.to_string());
let tool_state = ToolCallState {
id: HistoryId::ZERO,
call_id: Some("tool-1".to_string()),
status: ToolStatus::Success,
title: "Run formatter".to_string(),
duration: None,
arguments: vec![ToolArgument {
name: "command".to_string(),
value: ArgumentValue::Text("just fmt".to_string()),
}],
result_preview: Some(ToolResultPreview {
lines: vec!["Formatted 12 files".to_string()],
truncated: false,
}),
error_message: None,
};
chat.history_push(history_cell::ToolCallCell::new(tool_state));
let items = chat.export_auto_drive_items();
let coordinator = items
@@ -3779,6 +3802,12 @@ fn export_auto_drive_items_includes_cli_outputs() {
.map(|text| text.contains("Files changed") && text.contains("foo.rs"))
.unwrap_or(false)
}), "diff summary present");
assert!(items.iter().any(|item| {
message_text(item)
.map(|text| text.contains("Run formatter") && text.contains("Formatted 12 files"))
.unwrap_or(false)
}), "tool call output present");
}
#[test]
+20 -4
View File
@@ -6150,7 +6150,8 @@ impl HistoryCell for PatchSummaryCell {
}
fn desired_height(&self, width: u16) -> u16 {
let lines = self.build_lines(width);
// Trim leading/trailing empty lines to keep height in sync with render.
let lines = trim_empty_lines(self.build_lines(width));
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.line_count(width)
@@ -6159,13 +6160,28 @@ impl HistoryCell for PatchSummaryCell {
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
let text = Text::from(self.build_lines(area.width));
let bg_block = Block::default().style(Style::default().bg(crate::colors::background()));
// Render with trimmed lines and pre-clear the area to avoid residual glyphs
// when content shrinks (e.g., after width changes or trimming).
let lines = trim_empty_lines(self.build_lines(area.width));
let text = Text::from(lines);
let cell_bg = crate::colors::background();
let bg_block = Block::default().style(Style::default().bg(cell_bg));
// Proactively fill the full draw area with the background.
// This mirrors other cells that ensure a clean slate before drawing.
crate::util::buffer::fill_rect(
buf,
area,
Some(' '),
Style::default().bg(cell_bg).fg(crate::colors::text()),
);
Paragraph::new(text)
.block(bg_block)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.style(Style::default().bg(crate::colors::background()))
.style(Style::default().bg(cell_bg))
.render(area, buf);
}
}
+13
View File
@@ -97,6 +97,19 @@ description = "Custom LLM implementation"
args = ["--config", "/path/to/config.json"]
env = { MODEL_PATH = "/models/custom.bin", TEMP = "0.7" }
# Cloud agent example
[[agents]]
name = "cloud" # keep name = "cloud" so built-in /plan,/solve default lists include it
command = "cloud" # can be any CLI or path (e.g., "cloud-agent"); behavior keys off the agent name
enabled = true
read-only = true # cloud work typically runs remotely; local writes aren’t needed
description = "Submit tasks to a remote cloud worker"
# By default no extra flags are passed; the prompt is appended positionally.
# If your CLI expects a flag before the prompt, specify it here, e.g.:
# args = ["submit", "-p"]
# Optional environment:
# env = { CODEX_CLOUD_ENV = "prod" }
# Sandbox Configuration
# Controls file system access and permissions
sandbox-mode = "workspace-write"
+2
View File
@@ -424,6 +424,8 @@ tool_timeout_sec = 30
Sub-agents are orchestrated helper workflows you can trigger with slash commands (for example `/plan`, `/solve`, `/code`). Each entry under `[[subagents.commands]]` defines the slash command name, whether spawned agents run in read-only mode, which `agents` to launch, and extra guidance for both the orchestrator (Code) and the individual agents.
By default (when no `[[agents]]` are configured) Code advertises these agent names for multi-agent runs: `claude`, `gemini`, `qwen`, `code`, and `cloud`. You can override that set by defining `[[agents]]` entries or by specifying `agents = [ … ]` on a given `[[subagents.commands]]` entry.
```toml
[[subagents.commands]]
name = "context"
+4
View File
@@ -93,3 +93,7 @@ Implementation Notes
new command, please update this document to keep the UI and docs in sync.
- Prompt formatting for `/plan`, `/solve`, and `/code` lives in
`codex-rs/core/src/slash_commands.rs`.
When no `[[agents]]` are configured, the orchestrator advertises the
following agent names to the LLM for multi‑agent runs: `claude`, `gemini`,
`qwen`, `code`, and `cloud`. You can replace or pin this set via `[[agents]]`
or per‑command `[[subagents.commands]].agents`.
+5 -5
View File
@@ -8,13 +8,14 @@ Constraints
Essential Steps
◦ Prep:
· Stash if dirty. (Optional) create a throwaway local branch from `main` so you can abandon it if the merge goes sideways.
· Stash if dirty.
· Ensure the `upstream` remote exists (`git remote add upstream https://github.com/openai/codex.git` if needed).
· Pull latest `origin/main` and fetch `upstream/main` (`git fetch origin main && git fetch upstream main`).
◦ Merge:
· Merge `upstream/main` with `-X ours` (e.g. `git merge upstream/main -X ours`).
· Merge `upstream/main` with `-X ours` (`git merge upstream/main -X ours`). This ensures our branch incorporates the upstream history while defaulting to our side of conflicts.
· Review the resulting diffs and selectively bring in upstream improvements that `-X ours` skipped—especially in areas where upstream fixed bugs or polished UX. Use manual edits for the pieces you want.
· Resolve conflicts; don’t blanket keep ours for TUI — review diffs and integrate upstream fixes that don’t break our themes/browser/agents. Most of the time you WILL keep our TUI changes, but still review to make sure there's nothing we can merge which is missed.
· For TUI files (chatwidget, history_cell, bottom_pane/*), pull in upstream improvements selectively; keep our theme hooks (mod colors;), browser HUD, and agent UI.
· For TUI files (chatwidget, history_cell, bottom_pane/*), pull in upstream improvements selectively; keep our theme hooks (mod colors;), browser HUD, and agent UI. If upstream introduces TUI changes, please rewrite them to use our themes and helpers.
· Keep our versions of AGENTS.md, CHANGELOG.md and README.md
· Apply merge-policy cleanups: consult `.github/merge-policy.json` and remove any `purge_globs`/`perma_removed_paths` entries that reappear after the merge.
· Double-check TUI invariants: confirm the ordering tokens (`request_ordinal`, `output_index`, `sequence_number`) still exist under `codex-rs/tui/`.
@@ -25,8 +26,7 @@ Essential Steps
· Fix all warnings
◦ Finalize:
· Commit the result locally (no push required yet).
· Ensure `git merge-base --is-ancestor 5b038135dead11f9aa44ecbe5341a859b5ceec69 HEAD` (or whichever upstream commit you targeted) succeeds.
· If you created a temporary branch, fast-forward `main` locally or cherry-pick as needed once you’re satisfied.
· Ensure `git merge-base --is-ancestor <hash> HEAD` (of the latest upstream commit you merged) succeeds so `main` ends up with the full upstream history once you fast-forward.
· If you stashed files, unstash them.
Report: