diff --git a/Cargo.lock b/Cargo.lock index f3dfebe..b4086d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "rebon-acp" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3018,7 +3018,7 @@ dependencies = [ [[package]] name = "rebon-acp-client" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3039,7 +3039,7 @@ dependencies = [ [[package]] name = "rebon-agent-core" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3058,7 +3058,7 @@ dependencies = [ [[package]] name = "rebon-api" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3078,7 +3078,7 @@ dependencies = [ [[package]] name = "rebon-boa-runner" -version = "1.8.6" +version = "1.8.7" dependencies = [ "boa_engine", "boa_gc", @@ -3109,7 +3109,7 @@ dependencies = [ [[package]] name = "rebon-cli" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "arboard", @@ -3187,7 +3187,7 @@ dependencies = [ [[package]] name = "rebon-code-runner" -version = "1.8.6" +version = "1.8.7" dependencies = [ "futures-util", "libc", @@ -3200,7 +3200,7 @@ dependencies = [ [[package]] name = "rebon-command-seat" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-kernel", "rebon-slash-commands", @@ -3209,7 +3209,7 @@ dependencies = [ [[package]] name = "rebon-config" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "rebon-api", @@ -3227,7 +3227,7 @@ dependencies = [ [[package]] name = "rebon-config-seat" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-kernel", "rebon-types", @@ -3235,7 +3235,7 @@ dependencies = [ [[package]] name = "rebon-core" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3275,22 +3275,22 @@ dependencies = [ [[package]] name = "rebon-customselect" -version = "1.8.6" +version = "1.8.7" [[package]] name = "rebon-design-system" -version = "1.8.6" +version = "1.8.7" [[package]] name = "rebon-dialog" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-customselect", ] [[package]] name = "rebon-group" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "fs2", @@ -3302,7 +3302,7 @@ dependencies = [ [[package]] name = "rebon-harness" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3363,7 +3363,7 @@ dependencies = [ [[package]] name = "rebon-hooks" -version = "1.8.6" +version = "1.8.7" dependencies = [ "async-trait", "rebon-tools-core", @@ -3378,7 +3378,7 @@ dependencies = [ [[package]] name = "rebon-i18n-gen" -version = "1.8.6" +version = "1.8.7" dependencies = [ "serde", "serde_json", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "rebon-instructions" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-kernel", "rebon-session", @@ -3395,7 +3395,7 @@ dependencies = [ [[package]] name = "rebon-kernel" -version = "1.8.6" +version = "1.8.7" dependencies = [ "serde_json", "thiserror 2.0.18", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "rebon-kernel-seats" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "rebon-math" -version = "1.8.6" +version = "1.8.7" dependencies = [ "ratex-katex-fonts", "ratex-layout", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "rebon-mcp-channel" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "clap", @@ -3469,7 +3469,7 @@ dependencies = [ [[package]] name = "rebon-message-tui" -version = "1.8.6" +version = "1.8.7" dependencies = [ "pulldown-cmark", "ratatui", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "rebon-node-runtime" -version = "1.8.6" +version = "1.8.7" dependencies = [ "serde", "serde_json", @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "rebon-permissions" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-shell-policy", "rebon-tools-core", @@ -3503,11 +3503,11 @@ dependencies = [ [[package]] name = "rebon-picker" -version = "1.8.6" +version = "1.8.7" [[package]] name = "rebon-plugin-agents" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "rebon-plugin-browser" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -3561,7 +3561,7 @@ dependencies = [ [[package]] name = "rebon-plugin-computer-use" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3592,7 +3592,7 @@ dependencies = [ [[package]] name = "rebon-plugin-cron" -version = "1.8.6" +version = "1.8.7" dependencies = [ "async-trait", "rebon-core", @@ -3606,7 +3606,7 @@ dependencies = [ [[package]] name = "rebon-plugin-escalation" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3621,7 +3621,7 @@ dependencies = [ [[package]] name = "rebon-plugin-groups" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "rebon-plugin-host" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3669,7 +3669,7 @@ dependencies = [ [[package]] name = "rebon-plugin-image-gen" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3690,7 +3690,7 @@ dependencies = [ [[package]] name = "rebon-plugin-lsp-mcp" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3710,7 +3710,7 @@ dependencies = [ [[package]] name = "rebon-plugin-mcp" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3732,7 +3732,7 @@ dependencies = [ [[package]] name = "rebon-plugin-memory" -version = "1.8.6" +version = "1.8.7" dependencies = [ "async-trait", "rebon-api", @@ -3755,7 +3755,7 @@ dependencies = [ [[package]] name = "rebon-plugin-model-prompt" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-api", "rebon-core", @@ -3764,7 +3764,7 @@ dependencies = [ [[package]] name = "rebon-plugin-model-routing" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3787,7 +3787,7 @@ dependencies = [ [[package]] name = "rebon-plugin-monitor" -version = "1.8.6" +version = "1.8.7" dependencies = [ "async-trait", "futures-util", @@ -3804,7 +3804,7 @@ dependencies = [ [[package]] name = "rebon-plugin-node-host" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-kernel", "tracing", @@ -3812,7 +3812,7 @@ dependencies = [ [[package]] name = "rebon-plugin-notebook" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3830,7 +3830,7 @@ dependencies = [ [[package]] name = "rebon-plugin-onboarding" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -3856,7 +3856,7 @@ dependencies = [ [[package]] name = "rebon-plugin-package" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "rebon-hooks", @@ -3869,7 +3869,7 @@ dependencies = [ [[package]] name = "rebon-plugin-plan-mode" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3888,7 +3888,7 @@ dependencies = [ [[package]] name = "rebon-plugin-profile" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3910,7 +3910,7 @@ dependencies = [ [[package]] name = "rebon-plugin-protocol" -version = "1.8.6" +version = "1.8.7" dependencies = [ "serde", "serde_json", @@ -3920,7 +3920,7 @@ dependencies = [ [[package]] name = "rebon-plugin-remote" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "clap", @@ -3937,7 +3937,7 @@ dependencies = [ [[package]] name = "rebon-plugin-sandbox" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "chrono", @@ -3959,7 +3959,7 @@ dependencies = [ [[package]] name = "rebon-plugin-skill" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "rebon-plugin-structured-output" -version = "1.8.6" +version = "1.8.7" dependencies = [ "async-trait", "rebon-core", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "rebon-plugin-supervisor" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-node-runtime", "rebon-plugin-protocol", @@ -4010,7 +4010,7 @@ dependencies = [ [[package]] name = "rebon-plugin-tasks" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4036,7 +4036,7 @@ dependencies = [ [[package]] name = "rebon-plugin-updater" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "clap", @@ -4055,7 +4055,7 @@ dependencies = [ [[package]] name = "rebon-plugin-web" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "rebon-plugin-workflow" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4101,7 +4101,7 @@ dependencies = [ [[package]] name = "rebon-proto" -version = "1.8.6" +version = "1.8.7" dependencies = [ "libc", "rebon-types", @@ -4115,7 +4115,7 @@ dependencies = [ [[package]] name = "rebon-provider" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "rebon-rc-runner" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4188,7 +4188,7 @@ dependencies = [ [[package]] name = "rebon-render" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-design-system", "rebon-tools-core", @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "rebon-schema-gen" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-proto", "rebon-render", @@ -4213,7 +4213,7 @@ dependencies = [ [[package]] name = "rebon-session" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "fs2", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "rebon-session-host" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "chrono", @@ -4253,7 +4253,7 @@ dependencies = [ [[package]] name = "rebon-session-runtime" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "rebon-session-state" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-permissions", "rebon-proto", @@ -4316,14 +4316,14 @@ dependencies = [ [[package]] name = "rebon-shell" -version = "1.8.6" +version = "1.8.7" dependencies = [ "serde_json", ] [[package]] name = "rebon-shell-policy" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-tools-core", "serde_json", @@ -4332,7 +4332,7 @@ dependencies = [ [[package]] name = "rebon-slash-commands" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-types", "tracing", @@ -4340,14 +4340,14 @@ dependencies = [ [[package]] name = "rebon-spinner" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-types", ] [[package]] name = "rebon-tool" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "async-trait", @@ -4387,7 +4387,7 @@ dependencies = [ [[package]] name = "rebon-tools-core" -version = "1.8.6" +version = "1.8.7" dependencies = [ "anyhow", "libc", @@ -4401,7 +4401,7 @@ dependencies = [ [[package]] name = "rebon-tui" -version = "1.8.6" +version = "1.8.7" dependencies = [ "base64 0.22.1", "image 0.25.10", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "rebon-types" -version = "1.8.6" +version = "1.8.7" dependencies = [ "getrandom 0.2.17", "rebon-tools-core", @@ -4440,7 +4440,7 @@ dependencies = [ [[package]] name = "rebon-ui-seat" -version = "1.8.6" +version = "1.8.7" dependencies = [ "rebon-dialog", "rebon-kernel", @@ -4450,7 +4450,7 @@ dependencies = [ [[package]] name = "rebon-width" -version = "1.8.6" +version = "1.8.7" dependencies = [ "unicode-width", ] @@ -4794,7 +4794,7 @@ dependencies = [ [[package]] name = "sandbox-win" -version = "1.8.6" +version = "1.8.7" dependencies = [ "fs2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7c8523d..b5e5aa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ members = [ exclude = ["services/rc-server"] [workspace.package] -version = "1.8.6" +version = "1.8.7" edition = "2021" license = "Apache-2.0" rust-version = "1.80" diff --git a/crates/rebon-cli/src/exec.rs b/crates/rebon-cli/src/exec.rs index 57b6315..5a65c3c 100644 --- a/crates/rebon-cli/src/exec.rs +++ b/crates/rebon-cli/src/exec.rs @@ -40,7 +40,10 @@ use rebon_types::{AgentCapabilityMode, ContentBlock as AcpContentBlock, TextCont use crate::session::commands::effort::{resolve_thinking_from_effort, ThinkingOverrides}; -use std::path::PathBuf; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; /// Parsed inputs for `rebon exec`, assembled from the subcommand args plus the @@ -84,6 +87,52 @@ pub struct ExecArgs { pub max_duration_sec: Option, } +pub fn prepare_prompt( + words: &[String], + prompt_file: Option<&Path>, + json: bool, +) -> anyhow::Result { + resolve_prompt(words, prompt_file, std::io::stdin().lock()).inspect_err(|error| { + if json { + emit_error_once(&AtomicBool::new(false), &error.to_string()); + } + }) +} + +fn resolve_prompt( + words: &[String], + prompt_file: Option<&Path>, + mut stdin: impl Read, +) -> anyhow::Result { + let prompt = match prompt_file { + Some(path) => { + let source = if path == Path::new("-") { + "stdin".to_string() + } else { + format!("prompt file {}", path.display()) + }; + let mut bytes = Vec::new(); + if path == Path::new("-") { + stdin + .read_to_end(&mut bytes) + .with_context(|| format!("rebon exec: failed to read {source}"))?; + } else { + bytes = std::fs::read(path) + .with_context(|| format!("rebon exec: failed to read {source}"))?; + } + let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&bytes); + std::str::from_utf8(bytes) + .with_context(|| format!("rebon exec: {source} is not valid UTF-8"))? + .to_owned() + } + None => words.join(" "), + }; + if prompt.trim().is_empty() { + anyhow::bail!("rebon exec: empty prompt"); + } + Ok(prompt) +} + fn resolve_exec_thinking( effort: Option, provider_format: ProviderFormat, @@ -118,10 +167,29 @@ fn select_unattended_allow_option(options: &[PermissionQueryOption]) -> Option anyhow::Result<()> { - if args.prompt.trim().is_empty() { - anyhow::bail!("rebon exec: empty prompt"); + let json = args.json; + let error_emitted = Arc::new(AtomicBool::new(false)); + let outcome = run_inner(args, &error_emitted).await; + if json { + if let Err(error) = &outcome { + emit_error_once(&error_emitted, &error.to_string()); + } } + outcome +} +fn emit_error_once(error_emitted: &AtomicBool, message: &str) { + if !error_emitted.swap(true, Ordering::Relaxed) { + let message = if message.trim().is_empty() { + "rebon exec failed" + } else { + message + }; + emit(&serde_json::json!({ "type": "error", "message": message })); + } +} + +async fn run_inner(args: ExecArgs, error_emitted: &Arc) -> anyhow::Result<()> { // Say out loud what this process is, before anything builds a tool list // from it. `exec` has no one to approve a plan, answer a question, or // retry a call the classifier stopped, and the tools that need one of @@ -161,10 +229,20 @@ pub async fn run(args: ExecArgs) -> anyhow::Result<()> { // Observe every raw QueryEvent and project it onto stdout. The callback runs // synchronously inside the executor's single consume loop, so lines are // emitted in event order without interleaving. + let observer_error_emitted = Arc::clone(error_emitted); let observer = QueryEventObserver::new(move |event| { if json { for value in project_json(event) { - emit(&value); + if value["type"] == "error" { + emit_error_once( + &observer_error_emitted, + value["message"] + .as_str() + .expect("projected error has a message"), + ); + } else { + emit(&value); + } } } else { project_text(event); @@ -379,6 +457,15 @@ pub async fn run(args: ExecArgs) -> anyhow::Result<()> { _server_state.close_session(&session_id); rebon_core::system_prompt::remove_scratchpad_for(&cwd, &session_id); + report_outcome(json, &session_id, deadline_reached, result) +} + +fn report_outcome( + json: bool, + session_id: &str, + deadline_reached: bool, + result: Result, +) -> anyhow::Result<()> { match result { Ok(outcome) => { // A caller cannot tell "the harness stopped this" from "the user @@ -426,9 +513,6 @@ pub async fn run(args: ExecArgs) -> anyhow::Result<()> { } Err(err) => { let message = format!("{err:?}"); - if json { - emit(&serde_json::json!({ "type": "error", "message": message })); - } Err(anyhow::anyhow!("rebon exec turn failed: {message}")) } } @@ -600,6 +684,96 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn prompt_input_preserves_positional_words() { + let words = vec![" 检查\n项目".into(), "'quoted' --flag ".into()]; + assert_eq!( + resolve_prompt(&words, None, std::io::empty()).unwrap(), + " 检查\n项目 'quoted' --flag " + ); + } + + #[test] + fn prompt_input_reads_utf8_files_and_bom_without_trimming() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("提示词.txt"); + let content = " 第一行\r\n第二行 café\n\"quoted\"\t\n"; + for prefix in ["", "\u{feff}"] { + std::fs::write(&path, format!("{prefix}{content}")).unwrap(); + assert_eq!( + resolve_prompt(&[], Some(&path), std::io::empty()).unwrap(), + content + ); + } + } + + #[test] + fn prompt_input_reads_stdin_and_bom_without_trimming() { + let content = " 多行提示\n非 ASCII café\r\n"; + for prefix in ["", "\u{feff}"] { + let input = format!("{prefix}{content}"); + assert_eq!( + resolve_prompt(&[], Some(Path::new("-")), input.as_bytes()).unwrap(), + content + ); + } + } + + #[test] + fn prompt_input_rejects_empty_and_whitespace_from_all_sources() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.txt"); + assert!(resolve_prompt(&[], None, std::io::empty()).is_err()); + for input in ["", " \t\r\n\u{2003}"] { + assert!(resolve_prompt(&[input.into()], None, std::io::empty()).is_err()); + for prefix in ["", "\u{feff}"] { + let input = format!("{prefix}{input}"); + std::fs::write(&path, &input).unwrap(); + for error in [ + resolve_prompt(&[], Some(&path), std::io::empty()).unwrap_err(), + resolve_prompt(&[], Some(Path::new("-")), input.as_bytes()).unwrap_err(), + ] { + assert!(error.to_string().contains("empty prompt")); + } + } + } + } + + #[test] + fn prompt_input_rejects_missing_unreadable_and_invalid_utf8_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("input.txt"); + for unreadable in [path.as_path(), dir.path()] { + let error = resolve_prompt(&[], Some(unreadable), std::io::empty()).unwrap_err(); + assert!(error.to_string().contains("failed to read prompt file")); + assert!(error + .to_string() + .contains(&unreadable.display().to_string())); + } + for bytes in [vec![0xff], vec![0xef, 0xbb, 0xbf, 0xc3, 0x28]] { + std::fs::write(&path, &bytes).unwrap(); + let error = resolve_prompt(&[], Some(&path), std::io::empty()).unwrap_err(); + assert!(error.to_string().contains("not valid UTF-8")); + let error = resolve_prompt(&[], Some(Path::new("-")), bytes.as_slice()).unwrap_err(); + assert!(error.to_string().contains("stdin is not valid UTF-8")); + } + } + + #[test] + fn prompt_input_reports_stdin_read_errors() { + struct Unreadable; + impl Read for Unreadable { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "read failed", + )) + } + } + let error = resolve_prompt(&[], Some(Path::new("-")), Unreadable).unwrap_err(); + assert!(error.to_string().contains("failed to read stdin")); + } + fn permission_option(option_id: &str, kind: PermissionOptionKind) -> PermissionQueryOption { PermissionQueryOption { option_id: option_id.into(), diff --git a/crates/rebon-cli/src/main.rs b/crates/rebon-cli/src/main.rs index 964eae3..cd30c3b 100644 --- a/crates/rebon-cli/src/main.rs +++ b/crates/rebon-cli/src/main.rs @@ -435,9 +435,12 @@ enum Command { /// /// Example: `rebon exec --json --model gpt-5.4 "what is the weather in Beijing?"` Exec { - /// The prompt text to send (all trailing words are joined). - #[arg(value_name = "PROMPT", num_args = 1.., trailing_var_arg = true)] + /// 要发送的提示词文本(所有尾随词以空格连接),与 --prompt-file 互斥。 + #[arg(value_name = "PROMPT", num_args = 1.., trailing_var_arg = true, required_unless_present = "prompt_file")] prompt: Vec, + /// 从 UTF-8 文件读取提示词(支持 BOM);使用 - 从 stdin 读取。 + #[arg(long, value_name = "PATH", conflicts_with = "prompt")] + prompt_file: Option, /// Emit machine-readable JSONL events on stdout (one object per line). /// Without it, a compact human-readable trace is printed instead. #[arg(long = "json", default_value_t = false)] @@ -1409,14 +1412,6 @@ async fn async_main() -> anyhow::Result<()> { async fn route_main() -> anyhow::Result<()> { let startup_started = std::time::Instant::now(); let mut cli = Cli::parse(); - // Runs before any route can touch `config.json`: the seeding rule keys off - // "config exists but has no flag", so a first run must still look like a - // first run here. - rebon_config::migrate_claude_codex_fallback_default(); - // Provider definitions move to `~/.rebon/providers/` here, before any - // route resolves one. Reversible and non-fatal: a failure leaves - // `config.json` authoritative. - rebon_config::migrate_providers_to_store(); let route = classify_cli_startup(&cli)?; let cwd_scope = match cli.cwd.as_ref() { Some(cwd) => { @@ -1429,6 +1424,23 @@ async fn route_main() -> anyhow::Result<()> { } None => None, }; + let exec_prompt = match cli.command.as_ref() { + Some(Command::Exec { + prompt, + prompt_file, + json, + .. + }) => Some(exec::prepare_prompt(prompt, prompt_file.as_deref(), *json)?), + _ => None, + }; + // Runs before any route can touch `config.json`: the seeding rule keys off + // "config exists but has no flag", so a first run must still look like a + // first run here. + rebon_config::migrate_claude_codex_fallback_default(); + // Provider definitions move to `~/.rebon/providers/` here, before any + // route resolves one. Reversible and non-fatal: a failure leaves + // `config.json` authoritative. + rebon_config::migrate_providers_to_store(); match route { StartupRoute::AgentView => { init_tracing(true); @@ -1466,7 +1478,8 @@ async fn route_main() -> anyhow::Result<()> { ); match command { Command::Exec { - prompt, + prompt: _, + prompt_file: _, json, resume, ephemeral, @@ -1477,7 +1490,7 @@ async fn route_main() -> anyhow::Result<()> { max_duration, } => { return exec::run(exec::ExecArgs { - prompt: prompt.join(" "), + prompt: exec_prompt.expect("exec input was resolved before startup"), json, resume, ephemeral, @@ -1879,6 +1892,68 @@ mod tests { assert_eq!(cli.model.as_deref(), Some("gpt-5.6")); } + #[test] + fn exec_prompt_file_parses_paths_and_stdin() { + for path in ["prompt.txt", "提示词 with spaces.txt", "-"] { + let cli = Cli::try_parse_from(["rebon", "exec", "--prompt-file", path]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Exec { prompt, prompt_file, .. }) + if prompt.is_empty() && prompt_file == Some(PathBuf::from(path)) + )); + } + } + + #[test] + fn exec_prompt_file_requires_one_input_and_rejects_conflicts() { + for args in [vec!["rebon", "exec"], vec!["rebon", "exec", "--json"]] { + assert_eq!( + Cli::try_parse_from(args).unwrap_err().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + assert_eq!( + Cli::try_parse_from(["rebon", "exec", "--prompt-file"]) + .unwrap_err() + .kind(), + clap::error::ErrorKind::InvalidValue + ); + for path in ["prompt.txt", "-"] { + let error = + Cli::try_parse_from(["rebon", "exec", "--prompt-file", path, "positional prompt"]) + .unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + } + + #[test] + fn exec_prompt_file_keeps_trailing_positional_behavior() { + let cli = Cli::try_parse_from([ + "rebon", + "exec", + "inspect", + "--prompt-file", + "literal.txt", + "--json", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Some(Command::Exec { prompt, prompt_file: None, json: false, .. }) + if prompt == ["inspect", "--prompt-file", "literal.txt", "--json"] + )); + } + + #[test] + fn exec_prompt_file_help_describes_utf8_bom_and_stdin() { + let error = Cli::try_parse_from(["rebon", "exec", "--help"]).unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + let help = error.to_string(); + for expected in ["--prompt-file ", "PROMPT", "UTF-8", "BOM", "stdin"] { + assert!(help.contains(expected), "{help}"); + } + } + #[test] fn exec_configuration_flags_parse_after_subcommand() { let cli = Cli::parse_from([ @@ -1897,6 +1972,7 @@ mod tests { cli.command, Some(Command::Exec { prompt: vec!["inspect".to_string(), "the project".to_string()], + prompt_file: None, json: false, resume: None, ephemeral: false, @@ -1942,6 +2018,7 @@ mod tests { cli.command, Some(Command::Exec { prompt: vec!["inspect".to_string()], + prompt_file: None, json: false, resume: None, ephemeral: false, diff --git a/crates/rebon-cli/tests/exec_json.rs b/crates/rebon-cli/tests/exec_json.rs new file mode 100644 index 0000000..dafdaba --- /dev/null +++ b/crates/rebon-cli/tests/exec_json.rs @@ -0,0 +1,382 @@ +use std::path::PathBuf; +use std::process::{Output, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::process::Command; + +struct ExecFixture { + root: tempfile::TempDir, + home: PathBuf, + cwd: PathBuf, + _config_home: rebon_tool::tasks::test_support::TestConfigHome, +} + +impl ExecFixture { + fn new() -> Self { + let config_home = rebon_tool::tasks::test_support::TestConfigHome::new("exec-json"); + let root = tempfile::tempdir().expect("test root"); + let home = root.path().join("home"); + let cwd = root.path().join("cwd"); + std::fs::create_dir(&home).expect("config home"); + std::fs::create_dir(&cwd).expect("cwd"); + std::fs::write(home.join("config.json"), "{}").expect("config"); + std::fs::write(home.join("settings.json"), "{}").expect("settings"); + Self { + root, + home, + cwd, + _config_home: config_home, + } + } + + fn provider(&self, address: std::net::SocketAddr) { + let trust_key = rebon_session::cwd_identity(&self.cwd.to_string_lossy()); + std::fs::write( + self.home.join("config.json"), + json!({ + "activeCustomProvider": "mock", + "customProviders": [{ + "name": "mock", "format": "openai", + "baseUrl": format!("http://{address}/v1"), + "apiKey": "exec-test-not-a-secret", "model": "mock-model", + "models": ["mock-model"] + }], + "projects": { trust_key: { "hasTrustDialogAccepted": true } } + }) + .to_string(), + ) + .expect("mock config"); + } + + async fn run(&self, args: &[&str]) -> Output { + let binary = PathBuf::from(env!("CARGO_BIN_EXE_rebon")); + let mut command = Command::new(std::fs::canonicalize(&binary).expect("exec binary path")); + command.env_clear(); + for name in ["PATH", "SystemRoot", "WINDIR", "TEMP", "TMP"] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + command + .args(args) + .current_dir(&self.cwd) + .env("REBON_CONFIG_DIR", &self.home) + .env("HOME", self.root.path()) + .env("USERPROFILE", self.root.path()) + .env("NO_PROXY", "127.0.0.1,localhost") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + tokio::time::timeout(Duration::from_secs(120), command.output()) + .await + .expect("exec must finish and shut down the plugin plane") + .expect("run rebon exec") + } +} + +fn events(output: &Output) -> Vec { + let stdout = std::str::from_utf8(&output.stdout).expect("UTF-8 stdout"); + stdout + .lines() + .map(|line| { + serde_json::from_str(line) + .unwrap_or_else(|error| panic!("stdout must contain JSONL only: {error}: {stdout}")) + }) + .collect() +} + +fn assert_one_error(output: &Output) -> Vec { + assert!(!output.status.success(), "failure must exit nonzero"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("exec-test-not-a-secret")); + let events = events(output); + let errors: Vec<_> = events + .iter() + .filter(|event| event["type"] == "error") + .collect(); + assert_eq!(errors.len(), 1, "{events:?}"); + let error = errors[0]; + assert_eq!(error.as_object().expect("error object").len(), 2); + assert!(!error["message"] + .as_str() + .expect("error message") + .trim() + .is_empty()); + assert!(!error["message"] + .as_str() + .unwrap() + .contains("stack backtrace")); + events +} +fn event_types(events: &[Value]) -> Vec<&str> { + events + .iter() + .map(|event| event["type"].as_str().expect("event type")) + .collect() +} + +#[derive(Clone, Copy)] +enum Reply { + Success, + Failure, + Pending, +} + +struct Provider { + address: std::net::SocketAddr, + seen: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl Provider { + async fn start(reply: Reply) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("local provider"); + let address = listener.local_addr().expect("provider address"); + let seen = Arc::new(Mutex::new(Vec::new())); + let requests = Arc::clone(&seen); + let task = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.expect("provider connection"); + let mut buffer = Vec::new(); + let mut chunk = [0; 4096]; + let header_end = loop { + let read = stream.read(&mut chunk).await.expect("request headers"); + assert_ne!(read, 0); + buffer.extend_from_slice(&chunk[..read]); + if let Some(at) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + break at + 4; + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .expect("request content-length") + .trim() + .parse::() + .expect("body length"); + while buffer.len() < header_end + length { + let read = stream.read(&mut chunk).await.expect("request body"); + assert_ne!(read, 0); + buffer.extend_from_slice(&chunk[..read]); + } + let body: Value = + serde_json::from_slice(&buffer[header_end..]).expect("request JSON"); + requests + .lock() + .expect("requests poisoned") + .push(body.clone()); + let (status, content_type, response) = match reply { + Reply::Pending => std::future::pending().await, + Reply::Failure => ("400 Bad Request", "application/json", + json!({"error": {"message": "mock turn failed", "type": "invalid_request_error"}}).to_string()), + Reply::Success if body["stream"] == true => { + let chunks = [ + json!({"id": "c1", "object": "chat.completion.chunk", "model": "mock-model", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "done"}, "finish_reason": null}]}), + json!({"id": "c1", "object": "chat.completion.chunk", "model": "mock-model", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}), + json!({"id": "c1", "object": "chat.completion.chunk", "model": "mock-model", "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}}), + ]; + let mut sse: String = chunks.iter().map(|chunk| format!("data: {chunk}\n\n")).collect(); + sse.push_str("data: [DONE]\n\n"); + ("200 OK", "text/event-stream", sse) + } + Reply::Success => ("200 OK", "application/json", + json!({"id": "c1", "object": "chat.completion", "model": "mock-model", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}}).to_string()), + }; + let response = format!("HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{response}", response.len()); + stream + .write_all(response.as_bytes()) + .await + .expect("provider response"); + stream.shutdown().await.expect("provider shutdown"); + } + }); + Self { + address, + seen, + task, + } + } +} + +impl Drop for Provider { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn plugin_initialization_failure_emits_one_json_error() { + let fixture = ExecFixture::new(); + let plugin_dir = fixture.home.join("plugins"); + std::fs::create_dir(&plugin_dir).expect("plugins"); + std::fs::write(plugin_dir.join("installed.json"), "not JSON").expect("invalid plugin state"); + let output = fixture.run(&["exec", "--json", "hello"]).await; + let events = assert_one_error(&output); + assert_eq!(events.len(), 1); + assert!(events[0]["message"] + .as_str() + .unwrap() + .contains("failed to parse plugin state")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn turn_failure_does_not_repeat_the_observer_error() { + let fixture = ExecFixture::new(); + let provider = Provider::start(Reply::Failure).await; + fixture.provider(provider.address); + let output = fixture.run(&["exec", "--json", "hello"]).await; + let events = assert_one_error(&output); + assert_eq!(event_types(&events), ["session", "error"]); + assert!(events[1]["message"] + .as_str() + .unwrap() + .contains("mock turn failed")); + assert!(!provider.seen.lock().expect("requests poisoned").is_empty()); + let output = fixture.run(&["exec", "hello"]).await; + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn successful_turn_and_resume_keep_the_json_contract() { + let fixture = ExecFixture::new(); + let provider = Provider::start(Reply::Success).await; + fixture.provider(provider.address); + let output = fixture.run(&["exec", "--json", "first prompt"]).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let first = events(&output); + assert_eq!( + event_types(&first), + ["session", "message", "turn.completed", "result"] + ); + assert_eq!(first[1]["text"], "done"); + assert_eq!(first[3]["stopReason"], "end_turn"); + assert_eq!(first[3]["usage"]["input_tokens"], 10); + assert_eq!(first[3]["usage"]["output_tokens"], 2); + let session_id = first[0]["sessionId"].as_str().expect("session id"); + let output = fixture + .run(&["exec", "--json", "--resume", session_id, "second prompt"]) + .await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let resumed = events(&output); + assert_eq!(event_types(&resumed), event_types(&first)); + assert_eq!(resumed[0], first[0]); + assert_eq!(resumed[3]["sessionId"], session_id); + let requests = provider.seen.lock().expect("requests poisoned"); + let replay = requests.last().expect("resume request")["messages"].to_string(); + assert!(replay.contains("first prompt")); + assert!(replay.contains("done")); + assert!(replay.contains("second prompt")); + drop(requests); + let output = fixture.run(&["exec", "text prompt"]).await; + assert!(output.status.success()); + assert_eq!(std::str::from_utf8(&output.stdout).unwrap().trim(), "done"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn max_duration_keeps_cancelled_event_and_successful_result() { + let fixture = ExecFixture::new(); + let provider = Provider::start(Reply::Pending).await; + fixture.provider(provider.address); + let output = fixture + .run(&["exec", "--json", "--max-duration", "1", "hello"]) + .await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let events = events(&output); + assert_eq!(event_types(&events), ["session", "error", "result"]); + assert_eq!(events[1], json!({"type": "error", "message": "cancelled"})); + assert_eq!( + events[2], + json!({"type": "result", "sessionId": events[0]["sessionId"], + "stopReason": "max_duration", "usage": null}) + ); + assert!(!provider.seen.lock().expect("requests poisoned").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn slash_command_answer_keeps_message_and_result_without_a_turn() { + let fixture = ExecFixture::new(); + let provider = Provider::start(Reply::Success).await; + fixture.provider(provider.address); + let output = fixture.run(&["exec", "--json", "/runtime"]).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let events = events(&output); + assert_eq!(event_types(&events), ["session", "message", "result"]); + assert!(!events[1]["text"].as_str().expect("answer").is_empty()); + assert_eq!( + events[2], + json!({"type": "result", "sessionId": events[0]["sessionId"], + "stopReason": "command", "usage": null}) + ); + assert!(provider.seen.lock().expect("requests poisoned").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn blank_prompt_emits_one_json_error_and_text_mode_stays_text() { + let fixture = ExecFixture::new(); + for prompt in ["", " \t\n "] { + let output = fixture.run(&["exec", "--json", prompt]).await; + let events = assert_one_error(&output); + assert_eq!( + events, + [json!({"type": "error", "message": "rebon exec: empty prompt"})] + ); + } + let output = fixture.run(&["exec", " \t "]).await; + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("empty prompt")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn provider_configuration_failure_emits_one_json_error_before_session() { + let fixture = ExecFixture::new(); + let output = fixture + .run(&[ + "--provider", + "missing-test-provider", + "exec", + "--json", + "hello", + ]) + .await; + let events = assert_one_error(&output); + assert_eq!(events.len(), 1); + assert!(events[0]["message"] + .as_str() + .unwrap() + .contains("failed to build headless session")); + let output = fixture + .run(&["--provider", "missing-test-provider", "exec", "hello"]) + .await; + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); +} diff --git a/crates/rebon-cli/tests/exec_prompt_input.rs b/crates/rebon-cli/tests/exec_prompt_input.rs new file mode 100644 index 0000000..cbcd27e --- /dev/null +++ b/crates/rebon-cli/tests/exec_prompt_input.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +#[tokio::test(flavor = "multi_thread")] +async fn exec_input_errors_precede_config_and_runtime_startup() { + let _config_home = rebon_tool::tasks::test_support::TestConfigHome::new("exec-prompt-input"); + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("config-home"); + let empty = dir.path().join("empty.txt"); + let invalid = dir.path().join("invalid.txt"); + let missing = dir.path().join("missing.txt"); + std::fs::write(&empty, "\u{feff} \r\n\t").unwrap(); + std::fs::write(&invalid, [0xef, 0xbb, 0xbf, 0xff]).unwrap(); + + let cases = [ + (Vec::new(), Vec::new(), "required"), + (vec![" \t\n".to_string()], Vec::new(), "empty prompt"), + ( + vec!["--prompt-file".into(), empty.display().to_string()], + Vec::new(), + "empty prompt", + ), + ( + vec!["--prompt-file".into(), invalid.display().to_string()], + Vec::new(), + "not valid UTF-8", + ), + ( + vec!["--prompt-file".into(), missing.display().to_string()], + Vec::new(), + "failed to read prompt file", + ), + ( + vec!["--prompt-file".into(), dir.path().display().to_string()], + Vec::new(), + "failed to read prompt file", + ), + ( + vec!["--prompt-file".into(), "-".into()], + Vec::new(), + "empty prompt", + ), + ( + vec!["--prompt-file".into(), "-".into()], + b"\xef\xbb\xbf \r\n".to_vec(), + "empty prompt", + ), + ( + vec!["--prompt-file".into(), "-".into()], + vec![0xff], + "stdin is not valid UTF-8", + ), + ( + vec!["--prompt-file".into(), "-".into(), "conflict".into()], + Vec::new(), + "cannot be used with", + ), + ]; + let binary = PathBuf::from(env!("CARGO_BIN_EXE_rebon")) + .canonicalize() + .unwrap(); + for (args, input, expected) in cases { + for json in [false, true] { + let mut child = Command::new(&binary) + .arg("exec") + .args(json.then_some("--json")) + .args(&args) + .current_dir(dir.path()) + .env("REBON_CONFIG_DIR", &home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(&input).await.unwrap(); + drop(stdin); + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .expect("输入错误不应等待运行时启动") + .unwrap(); + assert!(!output.status.success(), "{args:?}"); + if json && expected != "required" && expected != "cannot be used with" { + let events: Vec = std::str::from_utf8(&output.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.len(), 1, "{args:?}: {events:?}"); + assert_eq!(events[0]["type"], "error"); + assert_eq!(events[0].as_object().unwrap().len(), 2); + assert!(events[0]["message"].as_str().unwrap().contains(expected)); + } else { + assert!(output.stdout.is_empty(), "{args:?}: {:?}", output.stdout); + } + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains(expected), "{args:?}: {stderr}"); + assert!(!home.exists(), "输入错误不应创建配置目录: {args:?}"); + } + } +} diff --git a/crates/rebon-group/src/agents.rs b/crates/rebon-group/src/agents.rs index 1c0c7db..d30b888 100644 --- a/crates/rebon-group/src/agents.rs +++ b/crates/rebon-group/src/agents.rs @@ -151,6 +151,27 @@ mod tests { assert_eq!(store.load(&group.id).unwrap().members.len(), 2); } + #[test] + fn every_known_agent_can_be_admitted_excluded_and_reenabled() { + let dir = tempfile::tempdir().unwrap(); + let store = GroupStore::new(dir.path()); + let group = store.create("clients", "/work/app").unwrap(); + for &agent in AgentKind::KNOWN { + store.set_agent_allowed(agent, false).unwrap(); + assert!( + store.join(&group.id, member(agent, "s1")).is_err(), + "{agent}" + ); + assert!(store.load(&group.id).unwrap().members.is_empty()); + store.set_agent_allowed(agent, true).unwrap(); + let joining = member(agent, "s1"); + store.join(&group.id, joining.clone()).unwrap(); + assert_eq!(store.load(&group.id).unwrap().members[0].agent, agent); + store.leave(&group.id, &joining.key()).unwrap(); + } + assert_eq!(store.agent_policy().unwrap(), AgentPolicy::default()); + } + #[test] fn switching_out_leaves_existing_members_where_they_are() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/rebon-group/src/deliver.rs b/crates/rebon-group/src/deliver.rs index ac50966..3bd7ad1 100644 --- a/crates/rebon-group/src/deliver.rs +++ b/crates/rebon-group/src/deliver.rs @@ -157,6 +157,7 @@ fn until_first_wake(mut pending: Pending) -> Pending { /// The hook events that can carry context into the model. const CONTEXT_EVENTS: &[&str] = &["UserPromptSubmit", "SessionStart", "PostToolUse"]; +const GEMINI_CONTEXT_EVENTS: &[&str] = &["BeforeAgent", "SessionStart", "AfterTool"]; /// `SessionStart` sources after which the context no longer holds what /// the group told it. @@ -164,11 +165,16 @@ const FRESH_SOURCES: &[&str] = &["clear", "compact", "resume"]; /// What `rebon group hook --agent ` prints for one hook `input` (the /// JSON the agent passes on stdin), or `None` to print nothing. The shape is -/// the one both Claude Code and Codex read: +/// the one Claude Code, Codex, Qwen Code, ZCode and Gemini CLI read: /// `{"hookSpecificOutput": {"hookEventName": …, "additionalContext": …}}`. pub fn hook_output(store: &GroupStore, agent: &str, input: &Value) -> Option { let event = input.get("hook_event_name").and_then(Value::as_str)?; - if !CONTEXT_EVENTS.contains(&event) { + let events = if agent == crate::AgentKind::GEMINI_CLI { + GEMINI_CONTEXT_EVENTS + } else { + CONTEXT_EVENTS + }; + if !events.contains(&event) { return None; } let session_id = input @@ -180,6 +186,26 @@ pub fn hook_output(store: &GroupStore, agent: &str, input: &Value) -> Option Option { - let codex = meta.get("x-codex-turn-metadata")?; - let id = ["session_id", "thread_id"] - .iter() - .find_map(|key| codex.get(*key).and_then(serde_json::Value::as_str)) - .map(str::trim) - .filter(|id| !id.is_empty())?; + let (agent, id) = if let Some(codex) = meta.get("x-codex-turn-metadata") { + ( + AgentKind::CODEX, + ["session_id", "thread_id"] + .iter() + .find_map(|key| codex.get(*key).and_then(serde_json::Value::as_str))?, + ) + } else { + let qwen = meta.get("qwen-code/invocation")?; + if qwen.get("version").and_then(serde_json::Value::as_u64) != Some(1) { + return None; + } + ( + AgentKind::QWEN_CODE, + qwen.get("sessionId").and_then(serde_json::Value::as_str)?, + ) + }; + let id = id.trim(); + if id.is_empty() { + return None; + } Some(Caller { - agent: AgentKind::CODEX.to_string(), + agent: agent.to_string(), session_id: id.to_string(), }) } @@ -119,10 +175,14 @@ pub fn detect() -> Option { } /// [`detect`] over a given environment and parent, for tests and for hosts -/// that know better than the process environment. +/// that know better than the process environment. `REBON_GROUP_AGENT` limits +/// detection to the configured host so a nested CLI cannot inherit its +/// launching agent's identity. pub fn resolve(env: &HashMap, parent: Option<&Parent>) -> Option { + let expected = env.get("REBON_GROUP_AGENT").map(|agent| agent.trim()); let present: Vec<(&SessionVar, &str)> = SESSION_VARS .iter() + .filter(|known| expected.is_none_or(|agent| known.agent == agent)) .filter_map(|known| { env.get(known.var) .map(|value| value.trim()) @@ -271,6 +331,56 @@ mod tests { } } + #[test] + fn known_agent_names_are_distinct_and_cover_common_clients() { + let names: std::collections::HashSet<_> = AgentKind::KNOWN.iter().copied().collect(); + assert_eq!(names.len(), AgentKind::KNOWN.len()); + for agent in [ + "pi", + "zcode", + "gemini-cli", + "qwen-code", + "kimi-cli", + "amp", + "cursor-agent", + "crush", + ] { + assert!(names.contains(agent), "{agent}"); + } + assert!(names + .iter() + .all(|name| !name.is_empty() && *name == name.to_lowercase())); + } + + #[test] + fn a_configured_external_host_does_not_inherit_the_launchers_identity() { + for &agent in AgentKind::KNOWN { + let inherited = env(&[ + ("REBON_GROUP_AGENT", agent), + ("REBON_SESSION_ID", "parent-rebon"), + ("CLAUDE_CODE_SESSION_ID", "parent-claude"), + ("GROK_SESSION_ID", "parent-grok"), + ]); + let caller = resolve(&inherited, Some(&parent(10, "rebon-cli.exe"))); + match agent { + AgentKind::REBON | AgentKind::CLAUDE_CODE | AgentKind::GROK => { + assert_eq!(caller.unwrap().agent, agent); + } + _ => assert_eq!(caller, None, "{agent}"), + } + } + assert_eq!( + resolve( + &env(&[ + ("REBON_GROUP_AGENT", "unknown"), + ("REBON_SESSION_ID", "parent") + ]), + None + ), + None + ); + } + #[test] fn one_session_variable_names_the_caller() { let caller = resolve(&env(&[("CLAUDE_CODE_SESSION_ID", "aa38901a")]), None).unwrap(); @@ -343,6 +453,47 @@ mod tests { ); } + #[test] + fn native_external_session_variables_are_used_only_for_the_configured_host() { + for (agent, variable) in [ + (AgentKind::PI, "PI_SESSION_ID"), + (AgentKind::GEMINI_CLI, "GEMINI_SESSION_ID"), + (AgentKind::QWEN_CODE, "QWEN_CODE_SESSION_ID"), + ] { + let values = env(&[ + ("REBON_GROUP_AGENT", agent), + ("REBON_SESSION_ID", "parent"), + (variable, " native-session "), + ]); + let caller = resolve(&values, Some(&parent(10, "rebon-cli.exe"))).unwrap(); + assert_eq!(caller.agent, agent); + assert_eq!(caller.session_id, "native-session"); + assert_eq!(resolve(&env(&[(variable, " ")]), None), None); + } + } + + #[test] + fn qwen_stdio_invocation_metadata_names_its_session() { + let caller = from_call_meta(&serde_json::json!({ + "qwen-code/invocation": { "version": 1, "sessionId": " qwen-session ", "promptId": "p" } + })) + .unwrap(); + assert_eq!(caller.agent, AgentKind::QWEN_CODE); + assert_eq!(caller.session_id, "qwen-session"); + for invocation in [ + serde_json::json!({ "version": 1, "sessionId": " " }), + serde_json::json!({ "version": 1, "sessionId": 1 }), + serde_json::json!({ "version": 2, "sessionId": "s" }), + serde_json::json!({ "sessionId": "s" }), + serde_json::json!({ "version": 1 }), + ] { + assert_eq!( + from_call_meta(&serde_json::json!({ "qwen-code/invocation": invocation })), + None + ); + } + } + #[test] fn this_process_has_a_parent() { assert!(parent_process().is_some_and(|parent| parent.pid != 0)); diff --git a/crates/rebon-group/src/tools_tests.rs b/crates/rebon-group/src/tools_tests.rs index a78bdbf..48aef5a 100644 --- a/crates/rebon-group/src/tools_tests.rs +++ b/crates/rebon-group/src/tools_tests.rs @@ -27,6 +27,64 @@ fn run( const ROOT: &str = "/work/app"; +#[test] +fn common_external_clients_bind_their_session_and_exchange_group_messages() { + for &agent in crate::identity::AgentKind::KNOWN { + let dir = tempfile::tempdir().unwrap(); + let store = GroupStore::new(dir.path()); + let mut who = None; + let joined = run( + &store, + &mut who, + ROOT, + GROUP_JOIN, + json!({ "group": "clients", "agent": agent, "session_id": "native-session", "alias": "worker" }), + ) + .unwrap(); + assert_eq!(who, caller(agent, "native-session")); + assert_eq!(joined["you"], "worker"); + let group_id = joined["group"]["id"].as_str().unwrap(); + let request = store + .post_as_user( + group_id, + Draft { + kind: EntryKind::Request, + to: Some("worker".into()), + re: None, + supersedes: None, + text: "verify the integration".into(), + }, + ) + .unwrap(); + let inbox = run(&store, &mut who, ROOT, GROUP_INBOX, json!({ "full": true })).unwrap(); + assert!(inbox["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["id"] == request.id.as_deref().unwrap())); + run( + &store, + &mut who, + ROOT, + GROUP_SEND, + json!({ "to": "user", "kind": "reply", "re": request.id, "text": "verified" }), + ) + .unwrap(); + run( + &store, + &mut who, + ROOT, + GROUP_REMEMBER, + json!({ "fact": "shared contract" }), + ) + .unwrap(); + let memory = run(&store, &mut who, ROOT, GROUP_RECALL, json!({})).unwrap(); + assert!(memory.to_string().contains("shared contract")); + run(&store, &mut who, ROOT, GROUP_LEAVE, json!({})).unwrap(); + assert!(store.load(group_id).unwrap().members.is_empty()); + } +} + #[test] fn every_schema_is_a_closed_object_and_names_are_unique() { let specs = specs(); diff --git a/npm/bare/package.json b/npm/bare/package.json index 97c2390..de226cc 100644 --- a/npm/bare/package.json +++ b/npm/bare/package.json @@ -1,6 +1,6 @@ { "name": "rebon", - "version": "1.8.6", + "version": "1.8.7", "description": "Rebon agent CLI for coding and more. Installs @rebon/cli.", "license": "Apache-2.0", "repository": { @@ -19,7 +19,7 @@ "rebon": "bin/rebon.js" }, "dependencies": { - "@rebon/cli": "^1.8.6" + "@rebon/cli": "^1.8.7" }, "files": [ "bin",