mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
merge code-branch-20251002-131120 via /merge
This commit is contained in:
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user