diff --git a/Cargo.lock b/Cargo.lock index 7e7ed2a..48fa86f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,7 +447,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "cli-sub-agent" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -604,7 +604,7 @@ dependencies = [ [[package]] name = "csa-acp" -version = "0.1.59" +version = "0.1.60" dependencies = [ "agent-client-protocol", "anyhow", @@ -623,7 +623,7 @@ dependencies = [ [[package]] name = "csa-config" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "csa-core" -version = "0.1.59" +version = "0.1.60" dependencies = [ "agent-teams", "chrono", @@ -653,7 +653,7 @@ dependencies = [ [[package]] name = "csa-executor" -version = "0.1.59" +version = "0.1.60" dependencies = [ "agent-teams", "anyhow", @@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "csa-hooks" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -692,7 +692,7 @@ dependencies = [ [[package]] name = "csa-lock" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "csa-mcp-hub" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "axum", @@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "csa-memory" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "async-trait", @@ -744,7 +744,7 @@ dependencies = [ [[package]] name = "csa-process" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "csa-core", @@ -761,7 +761,7 @@ dependencies = [ [[package]] name = "csa-resource" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "csa-core", @@ -776,7 +776,7 @@ dependencies = [ [[package]] name = "csa-scheduler" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -794,7 +794,7 @@ dependencies = [ [[package]] name = "csa-session" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "csa-todo" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "chrono", @@ -3903,7 +3903,7 @@ dependencies = [ [[package]] name = "weave" -version = "0.1.59" +version = "0.1.60" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 32c8af9..766c915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.59" +version = "0.1.60" edition = "2024" rust-version = "1.85" license = "Apache-2.0" diff --git a/crates/cli-sub-agent/src/debate_cmd.rs b/crates/cli-sub-agent/src/debate_cmd.rs index f3e2ceb..a88f025 100644 --- a/crates/cli-sub-agent/src/debate_cmd.rs +++ b/crates/cli-sub-agent/src/debate_cmd.rs @@ -11,11 +11,11 @@ use crate::debate_errors::{DebateErrorKind, classify_execution_error, classify_e use crate::run_helpers::read_prompt; use csa_config::global::{heterogeneous_counterpart, select_heterogeneous_tool}; use csa_config::{GlobalConfig, ProjectConfig}; -use csa_core::types::ToolName; +use csa_core::types::{OutputFormat, ToolName}; use crate::debate_cmd_output::{ - append_debate_artifacts_to_result, extract_debate_summary, format_debate_stdout_summary, - persist_debate_output_artifacts, render_debate_output, + append_debate_artifacts_to_result, extract_debate_summary, format_debate_stdout_text, + persist_debate_output_artifacts, render_debate_output, render_debate_stdout_json, }; /// Debate execution mode indicating model diversity level. @@ -27,7 +27,11 @@ pub(crate) enum DebateMode { SameModelAdversarial, } -pub(crate) async fn handle_debate(args: DebateArgs, current_depth: u32) -> Result { +pub(crate) async fn handle_debate( + args: DebateArgs, + current_depth: u32, + output_format: OutputFormat, +) -> Result { // 1. Determine project root let project_root = crate::pipeline::determine_project_root(args.cd.as_deref())?; @@ -239,12 +243,35 @@ pub(crate) async fn handle_debate(args: DebateArgs, current_depth: u32) -> Resul let artifacts = persist_debate_output_artifacts(&session_dir, &debate_summary, &output)?; append_debate_artifacts_to_result(&project_root, &execution.meta_session_id, &artifacts)?; - // 10. Print brief summary only. - println!("{}", format_debate_stdout_summary(&debate_summary)); + let rendered_output = render_debate_cli_output( + output_format, + &debate_summary, + &output, + &execution.meta_session_id, + )?; + if rendered_output.ends_with('\n') { + print!("{rendered_output}"); + } else { + println!("{rendered_output}"); + } Ok(execution.execution.exit_code) } +fn render_debate_cli_output( + output_format: OutputFormat, + debate_summary: &crate::debate_cmd_output::DebateSummary, + transcript: &str, + meta_session_id: &str, +) -> Result { + match output_format { + OutputFormat::Text => Ok(format_debate_stdout_text(debate_summary, transcript)), + OutputFormat::Json => { + render_debate_stdout_json(debate_summary, transcript, meta_session_id) + } + } +} + const STILL_WORKING_BACKOFF: Duration = Duration::from_secs(5); fn resolve_debate_tool( diff --git a/crates/cli-sub-agent/src/debate_cmd_output.rs b/crates/cli-sub-agent/src/debate_cmd_output.rs index 17fc8ac..571da9a 100644 --- a/crates/cli-sub-agent/src/debate_cmd_output.rs +++ b/crates/cli-sub-agent/src/debate_cmd_output.rs @@ -136,6 +136,54 @@ pub(crate) fn format_debate_stdout_summary(summary: &DebateSummary) -> String { ) } +pub(crate) fn format_debate_stdout_text(summary: &DebateSummary, transcript: &str) -> String { + let mut rendered = String::new(); + rendered.push_str(&format_debate_stdout_summary(summary)); + rendered.push('\n'); + + if !transcript.is_empty() { + rendered.push('\n'); + rendered.push_str(transcript); + if !transcript.ends_with('\n') { + rendered.push('\n'); + } + } + + rendered +} + +#[derive(Debug, Serialize)] +struct DebateJsonOutput<'a> { + verdict: &'a str, + confidence: &'a str, + summary: &'a str, + key_points: &'a [String], + mode: &'static str, + transcript: &'a str, + meta_session_id: &'a str, +} + +pub(crate) fn render_debate_stdout_json( + summary: &DebateSummary, + transcript: &str, + meta_session_id: &str, +) -> Result { + let payload = DebateJsonOutput { + verdict: summary.verdict.as_str(), + confidence: summary.confidence.as_str(), + summary: summary.summary.as_str(), + key_points: &summary.key_points, + mode: match summary.mode { + DebateMode::Heterogeneous => "heterogeneous", + DebateMode::SameModelAdversarial => "same-model-adversarial", + }, + transcript, + meta_session_id, + }; + + serde_json::to_string_pretty(&payload).context("Failed to serialize debate JSON output") +} + pub(crate) fn render_debate_output( tool_output: &str, meta_session_id: &str, @@ -314,6 +362,8 @@ fn is_non_summary_line(line: &str) -> bool { || line.starts_with("```") || line.starts_with("- ") || line.starts_with("* ") + || line.starts_with("\nDetailed transcript\n"; + let text = format_debate_stdout_text(&summary, transcript); + + assert!(text.starts_with("Debate verdict: REVISE")); + assert!(text.contains("Needs stronger evidence.")); + assert!(text.contains("Detailed transcript")); +} + +#[test] +fn render_debate_stdout_json_outputs_valid_payload() { + let summary = DebateSummary { + verdict: "APPROVE".to_string(), + confidence: "high".to_string(), + summary: "Ship with safeguards.".to_string(), + key_points: vec!["Bounded retries".to_string()], + mode: DebateMode::SameModelAdversarial, + }; + let transcript = "Full transcript body\nCSA Meta Session ID: 01META\n"; + let json = render_debate_stdout_json(&summary, transcript, "01META").unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["verdict"], "APPROVE"); + assert_eq!(parsed["confidence"], "high"); + assert_eq!(parsed["mode"], "same-model-adversarial"); + assert_eq!(parsed["meta_session_id"], "01META"); + assert!( + parsed["transcript"] + .as_str() + .unwrap() + .contains("Full transcript body") + ); +} + +#[test] +fn extract_one_line_summary_ignores_csa_section_marker_lines() { + let output = r#" + + +Summary: Keep full debate transcript in stdout. +"#; + let summary = extract_one_line_summary(output, "fallback"); + assert_eq!(summary, "Keep full debate transcript in stdout."); +} + #[test] fn persist_debate_output_artifacts_writes_json_and_markdown() { let tmp = tempfile::TempDir::new().unwrap(); @@ -503,6 +556,22 @@ fn parse_debate_args(argv: &[&str]) -> crate::cli::DebateArgs { } } +#[test] +fn debate_cli_parses_global_json_format() { + use crate::cli::{Cli, Commands}; + use clap::Parser; + use csa_core::types::OutputFormat; + + let cli = Cli::try_parse_from(["csa", "--format", "json", "debate", "question"]) + .expect("cli should parse global json format for debate"); + + assert!(matches!(cli.format, OutputFormat::Json)); + match cli.command { + Commands::Debate(args) => assert_eq!(args.question.as_deref(), Some("question")), + _ => panic!("expected debate subcommand"), + } +} + #[test] fn debate_cli_parses_timeout_flag() { let args = parse_debate_args(&["csa", "debate", "--timeout", "120", "question"]); @@ -618,6 +687,26 @@ fn debate_stream_mode_explicit_no_stream() { assert!(matches!(mode, csa_process::StreamMode::BufferOnly)); } +#[test] +fn render_debate_cli_output_respects_json_format() { + use csa_core::types::OutputFormat; + + let summary = DebateSummary { + verdict: "REVISE".to_string(), + confidence: "medium".to_string(), + summary: "Need more evidence.".to_string(), + key_points: vec!["Point A".to_string()], + mode: DebateMode::Heterogeneous, + }; + + let rendered = + render_debate_cli_output(OutputFormat::Json, &summary, "Transcript body", "01META") + .unwrap(); + let parsed: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["meta_session_id"], "01META"); + assert_eq!(parsed["transcript"], "Transcript body"); +} + // --- resolve_debate_thinking tests --- #[test] diff --git a/crates/cli-sub-agent/src/main.rs b/crates/cli-sub-agent/src/main.rs index 03af2f2..b4504ec 100644 --- a/crates/cli-sub-agent/src/main.rs +++ b/crates/cli-sub-agent/src/main.rs @@ -322,7 +322,7 @@ async fn run() -> Result<()> { std::process::exit(exit_code); } Commands::Debate(args) => { - let exit_code = debate_cmd::handle_debate(args, current_depth).await?; + let exit_code = debate_cmd::handle_debate(args, current_depth, output_format).await?; std::process::exit(exit_code); } Commands::Doctor => {