From dac0278218588993625a0694d00527fe797cebeb Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Fri, 17 Jul 2026 11:52:05 +0000 Subject: [PATCH] huskies: merge 1188 story Merge tool results return a summary, not the full gate log (35KB per call) --- server/src/agents/merge/mod.rs | 8 + .../src/agents/pool/pipeline/merge/runner.rs | 17 +- .../src/agents/pool/pipeline/merge/status.rs | 2 + server/src/http/mcp/merge_tools.rs | 146 ++++++++++- server/src/service/merge/io.rs | 142 +++++++++++ server/src/service/merge/mod.rs | 4 + server/src/service/merge/status.rs | 1 + server/src/service/merge/summary.rs | 239 ++++++++++++++++++ 8 files changed, 545 insertions(+), 14 deletions(-) create mode 100644 server/src/service/merge/summary.rs diff --git a/server/src/agents/merge/mod.rs b/server/src/agents/merge/mod.rs index 84a16273..ec869c37 100644 --- a/server/src/agents/merge/mod.rs +++ b/server/src/agents/merge/mod.rs @@ -103,6 +103,9 @@ pub struct MergeJob { /// than the current server's boot time. This survives `rebuild_and_restart` /// (which re-execs and keeps the same PID). pub server_start_time: f64, + /// Unix timestamp (seconds) when this merge job started, used to compute + /// elapsed time for a still-`Running` job. + pub started_at: f64, } /// Result of a mergemaster merge operation. @@ -113,6 +116,11 @@ pub struct MergeReport { pub result: MergeResult, pub worktree_cleaned_up: bool, pub story_archived: bool, + /// Path (relative to the project root) of the full untruncated report + /// written by `service::merge::io::write_merge_report`, if the write + /// succeeded. + #[serde(default)] + pub report_path: Option, } #[cfg(test)] diff --git a/server/src/agents/pool/pipeline/merge/runner.rs b/server/src/agents/pool/pipeline/merge/runner.rs index c1c102b4..84035142 100644 --- a/server/src/agents/pool/pipeline/merge/runner.rs +++ b/server/src/agents/pool/pipeline/merge/runner.rs @@ -217,7 +217,7 @@ impl AgentPool { // retry_count=1 so maybe_inject_gate_failure injects gate output // into --append-system-prompt on the fixup spawn. // transition_to_merge_failure also writes ContentKey::GateOutput. - let display = kind.display_reason(); + let display = crate::service::merge::summarize_merge_failure_kind(&kind); let _ = crate::agents::lifecycle::transition_to_merge_failure(sid.as_str(), kind); match crate::agents::lifecycle::move_story_to_stage(&sid, "current") { @@ -258,7 +258,7 @@ impl AgentPool { // Transition through the state machine (Merge → MergeFailure). // Only send the notification when the stage actually changed; if the // story was already in MergeFailure (self-loop), suppress the duplicate. - let display = kind.display_reason(); + let display = crate::service::merge::summarize_merge_failure_kind(&kind); let should_notify = match crate::agents::lifecycle::transition_to_merge_failure( sid.as_str(), kind, @@ -352,14 +352,26 @@ impl AgentPool { merge_result, crate::agents::merge::MergeResult::Success { .. } ) { + let report_path = crate::service::merge::io::write_merge_report( + project_root, + story_id, + merge_result.output(), + ); return Ok(crate::agents::merge::MergeReport { story_id: story_id.to_string(), result: merge_result, worktree_cleaned_up: false, story_archived: false, + report_path, }); } + let report_path = crate::service::merge::io::write_merge_report( + project_root, + story_id, + merge_result.output(), + ); + let story_archived = crate::agents::lifecycle::move_story_to_done(story_id).is_ok(); // Story 1178: only delete the feature branch once the state transition @@ -389,6 +401,7 @@ impl AgentPool { result: merge_result, worktree_cleaned_up, story_archived, + report_path, }) } } diff --git a/server/src/agents/pool/pipeline/merge/status.rs b/server/src/agents/pool/pipeline/merge/status.rs index 12fba726..adb2e65f 100644 --- a/server/src/agents/pool/pipeline/merge/status.rs +++ b/server/src/agents/pool/pipeline/merge/status.rs @@ -29,6 +29,7 @@ impl AgentPool { }, worktree_cleaned_up: false, story_archived: false, + report_path: None, }); (crate::agents::merge::MergeJobStatus::Completed(report), 0.0) } @@ -41,6 +42,7 @@ impl AgentPool { story_id: story_id.to_string(), status, server_start_time, + started_at: view.started_at, }) } } diff --git a/server/src/http/mcp/merge_tools.rs b/server/src/http/mcp/merge_tools.rs index 50ff602f..3f38c3f9 100644 --- a/server/src/http/mcp/merge_tools.rs +++ b/server/src/http/mcp/merge_tools.rs @@ -82,9 +82,15 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + let elapsed_seconds = (now - job.started_at).max(0.0); serde_json::to_string_pretty(&json!({ "story_id": story_id, "status": "running", + "elapsed_seconds": elapsed_seconds, "message": "Merge pipeline is still running." })) .map_err(|e| format!("Serialization error: {e}")) @@ -92,24 +98,27 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result { use crate::agents::merge::MergeResult; let status_msg = crate::service::merge::format_merge_status_message(report); - let (success, had_conflicts, conflicts_resolved, conflict_details, gates_passed, gate_output) = + let (success, had_conflicts, conflicts_resolved, conflict_details, gates_passed) = match &report.result { - MergeResult::Success { conflicts_resolved, conflict_details, gate_output } => { - (true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true, gate_output.clone()) + MergeResult::Success { conflicts_resolved, conflict_details, .. } => { + (true, *conflicts_resolved, *conflicts_resolved, conflict_details.clone(), true) } - MergeResult::Conflict { details, output } => { - (false, true, false, details.clone(), false, output.clone()) + MergeResult::Conflict { details, .. } => { + (false, true, false, details.clone(), false) } - MergeResult::GateFailure { output, .. } => { - (false, false, false, None, false, output.clone()) + MergeResult::GateFailure { .. } => { + (false, false, false, None, false) } - MergeResult::NoCommits { output } => { - (false, false, false, None, false, output.clone()) + MergeResult::NoCommits { .. } => { + (false, false, false, None, false) } - MergeResult::Other { output, conflict_details } => { - (false, false, false, conflict_details.clone(), false, output.clone()) + MergeResult::Other { conflict_details, .. } => { + (false, false, false, conflict_details.clone(), false) } }; + // AC1: condensed digest instead of the full (potentially 35KB+) + // gate_output — successful merges carry no summary at all. + let gate_summary = crate::service::merge::summarize_merge_result(&report.result); serde_json::to_string_pretty(&json!({ "story_id": story_id, @@ -119,7 +128,8 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result 0.0, + "elapsed_seconds must reflect time since started_at=0.0: {v}" + ); + assert!( + v.get("gate_output").is_none() && v.get("gate_summary").is_none(), + "a running job must carry no gate data: {v}" + ); + } + + /// AC1: a successful merge's response carries no gate summary at all. + #[test] + fn tool_get_merge_status_completed_success_has_no_gate_summary() { + crate::crdt_state::init_for_test(); + let report = crate::agents::merge::MergeReport { + story_id: "51_story_ok".to_string(), + result: crate::agents::merge::MergeResult::Success { + conflicts_resolved: false, + conflict_details: None, + gate_output: "all tests passed".to_string(), + }, + worktree_cleaned_up: true, + story_archived: true, + report_path: Some(".huskies/merge_reports/51_story_ok-1.log".to_string()), + }; + let report_json = serde_json::to_string(&report).unwrap(); + crate::crdt_state::write_merge_job( + "51_story_ok", + "completed", + 1.0, + Some(2.0), + Some(&report_json), + ); + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_merge_status(&json!({"story_id": "51_story_ok"}), &ctx).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!( + v["gate_summary"].is_null(), + "success must carry no gate summary: {v}" + ); + assert_eq!(v["report_path"], ".huskies/merge_reports/51_story_ok-1.log"); + } + + /// AC1/AC2: a gate-failure response carries a condensed summary (failing + /// gate + parsed test names + tail), plus the full-report path — not the + /// raw multi-KB output. + #[test] + fn tool_get_merge_status_completed_gate_failure_has_condensed_summary() { + crate::crdt_state::init_for_test(); + let output = "running 1 test\ntest foo::bar ... FAILED\n\n\ + failures:\n foo::bar\n\ntest result: FAILED. 0 passed; 1 failed\n" + .to_string(); + let report = crate::agents::merge::MergeReport { + story_id: "52_story_fail".to_string(), + result: crate::agents::merge::MergeResult::GateFailure { + output, + failure_kind: Some(crate::agents::gates::GateFailureKind::Test), + }, + worktree_cleaned_up: false, + story_archived: false, + report_path: Some(".huskies/merge_reports/52_story_fail-1.log".to_string()), + }; + let report_json = serde_json::to_string(&report).unwrap(); + crate::crdt_state::write_merge_job( + "52_story_fail", + "completed", + 1.0, + Some(2.0), + Some(&report_json), + ); + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_merge_status(&json!({"story_id": "52_story_fail"}), &ctx).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let summary = v["gate_summary"] + .as_str() + .expect("gate_summary must be present on failure"); + assert!( + summary.contains("Failing gate: tests"), + "summary: {summary}" + ); + assert!( + summary.contains("Failing tests: foo::bar"), + "summary: {summary}" + ); + assert_eq!( + v["report_path"], + ".huskies/merge_reports/52_story_fail-1.log" + ); + } + // tool_get_merge_status_returns_running removed: depends on // tool_merge_agent_work which now blocks indefinitely in a poll loop. diff --git a/server/src/service/merge/io.rs b/server/src/service/merge/io.rs index 12f51c23..36d6601a 100644 --- a/server/src/service/merge/io.rs +++ b/server/src/service/merge/io.rs @@ -3,3 +3,145 @@ //! Currently, the bulk of the merge I/O is handled by `crate::agents::merge` //! and `crate::io::story_metadata`. This file is the designated home for any //! future I/O helpers that are extracted from merge-related MCP handlers. + +use std::path::{Path, PathBuf}; + +/// Directory (relative to the project root) where full untruncated merge +/// reports are written, one file per merge attempt. +const MERGE_REPORTS_DIR: &str = "merge_reports"; + +/// Maximum number of report files retained per story; older files beyond +/// this count are pruned after each write. +const MAX_REPORTS_PER_STORY: usize = 5; + +/// Write the full untruncated merge output to +/// `.huskies/merge_reports/{story_id}-{unix_timestamp}.log` and prune older +/// reports for the same story beyond [`MAX_REPORTS_PER_STORY`]. +/// +/// Returns the path to the written file (relative to `project_root`) for +/// inclusion in tool responses, or `None` if the write failed — a report is +/// a convenience artifact, so a filesystem hiccup here must not fail the +/// merge itself. +pub fn write_merge_report(project_root: &Path, story_id: &str, output: &str) -> Option { + let dir = project_root.join(".huskies").join(MERGE_REPORTS_DIR); + std::fs::create_dir_all(&dir).ok()?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let file_name = format!("{story_id}-{timestamp}.log"); + std::fs::write(dir.join(&file_name), output).ok()?; + + prune_old_reports(&dir, story_id); + + Some(format!(".huskies/{MERGE_REPORTS_DIR}/{file_name}")) +} + +/// Delete the oldest report files for `story_id` in `dir` beyond +/// [`MAX_REPORTS_PER_STORY`]. Filenames embed a unix timestamp, so lexical +/// sort order matches chronological order. +fn prune_old_reports(dir: &Path, story_id: &str) { + let prefix = format!("{story_id}-"); + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + let mut paths: Vec = read_dir + .flatten() + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".log")) + }) + .map(|entry| entry.path()) + .collect(); + paths.sort(); + + if paths.len() > MAX_REPORTS_PER_STORY { + for old in &paths[..paths.len() - MAX_REPORTS_PER_STORY] { + let _ = std::fs::remove_file(old); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_merge_report_creates_file_with_full_output() { + let tmp = tempfile::tempdir().unwrap(); + let output = "full untruncated gate output\nline 2\n"; + let path = write_merge_report(tmp.path(), "42_story_foo", output) + .expect("write_merge_report should succeed"); + assert!(path.starts_with(".huskies/merge_reports/42_story_foo-")); + assert!(path.ends_with(".log")); + let full_path = tmp.path().join(&path); + assert_eq!(std::fs::read_to_string(full_path).unwrap(), output); + } + + #[test] + fn write_merge_report_prunes_beyond_max_per_story() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR); + std::fs::create_dir_all(&dir).unwrap(); + + // Seed MAX_REPORTS_PER_STORY + 2 files with distinct, ordered names + // (avoids relying on real-clock timestamps to establish ordering). + for i in 0..(MAX_REPORTS_PER_STORY + 2) { + std::fs::write( + dir.join(format!("77_story-{i:010}.log")), + format!("report {i}"), + ) + .unwrap(); + } + + prune_old_reports(&dir, "77_story"); + + let remaining: Vec = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(remaining.len(), MAX_REPORTS_PER_STORY); + // The two oldest (lowest-numbered) files must be gone. + assert!(!remaining.contains(&"77_story-0000000000.log".to_string())); + assert!(!remaining.contains(&"77_story-0000000001.log".to_string())); + // The newest must survive. + assert!(remaining.contains(&format!("77_story-{:010}.log", MAX_REPORTS_PER_STORY + 1))); + } + + #[test] + fn write_merge_report_prune_only_affects_matching_story() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("99_other-0000000000.log"), "keep me").unwrap(); + + for i in 0..(MAX_REPORTS_PER_STORY + 2) { + std::fs::write( + dir.join(format!("77_story-{i:010}.log")), + format!("report {i}"), + ) + .unwrap(); + } + + prune_old_reports(&dir, "77_story"); + + assert!( + dir.join("99_other-0000000000.log").exists(), + "pruning one story's reports must not touch another story's files" + ); + } + + #[test] + fn write_merge_report_returns_none_when_project_root_is_unwritable_file() { + // Passing a path that is a regular file (not a directory) makes + // create_dir_all fail, exercising the graceful-`None` path. + let tmp = tempfile::tempdir().unwrap(); + let not_a_dir = tmp.path().join("not_a_dir"); + std::fs::write(¬_a_dir, "i am a file").unwrap(); + assert!(write_merge_report(¬_a_dir, "1_story", "output").is_none()); + } +} diff --git a/server/src/service/merge/mod.rs b/server/src/service/merge/mod.rs index 3171969c..abe4333b 100644 --- a/server/src/service/merge/mod.rs +++ b/server/src/service/merge/mod.rs @@ -10,9 +10,13 @@ pub mod io; /// Pure merge-status message formatting. pub mod status; +/// Pure condensed gate/merge-failure summaries. +pub mod summary; #[allow(unused_imports)] pub use status::format_merge_status_message; +#[allow(unused_imports)] +pub use summary::{summarize_merge_failure_kind, summarize_merge_result}; // ── Error type ──────────────────────────────────────────────────────────────── diff --git a/server/src/service/merge/status.rs b/server/src/service/merge/status.rs index 33086180..d38a291a 100644 --- a/server/src/service/merge/status.rs +++ b/server/src/service/merge/status.rs @@ -57,6 +57,7 @@ mod tests { result, worktree_cleaned_up: false, story_archived: false, + report_path: None, } } diff --git a/server/src/service/merge/summary.rs b/server/src/service/merge/summary.rs new file mode 100644 index 00000000..ce8c94e6 --- /dev/null +++ b/server/src/service/merge/summary.rs @@ -0,0 +1,239 @@ +//! Condensed summaries of gate/merge failure output for MCP tool responses +//! and chat notifications. +//! +//! Full gate/test output can run into hundreds of KB; these pure functions +//! reduce it to a bounded, human-scannable digest: the failing gate's name, +//! parsed failing test names (when the output looks like a `cargo test` +//! run), and the last [`SUMMARY_TAIL_LINES`] lines of raw output. No I/O. + +use crate::agents::gates::GateFailureKind; +use crate::agents::merge::MergeResult; +use crate::pipeline_state::MergeFailureKind; +use crate::service::notifications::format::truncate_gate_output; + +/// Number of trailing output lines retained in a gate-failure summary. +/// +/// Kept a few lines under `notifications::format::MERGE_FAILURE_TAIL_LINES` +/// (30): chat notifications re-apply that cap on top of this summary's +/// `Failing gate:`/`Failing tests:`/`--- output tail ---` header lines, and +/// the total must stay at or under 30 lines so that second pass is a no-op +/// instead of truncating the header off the front of an already-shaped +/// summary. +pub const SUMMARY_TAIL_LINES: usize = 25; + +/// Parse `cargo test` output for the list of failing test names. +/// +/// libtest prints one or more `failures:` sections listing the fully +/// qualified name of each failing test on its own indented line, terminated +/// by a blank line or the `test result:` summary line. Returns names in +/// first-seen order with duplicates removed (a name can repeat across the +/// `failures:` section and the final summary re-listing). +pub fn extract_failing_test_names(output: &str) -> Vec { + let mut names = Vec::new(); + let mut in_failures = false; + for line in output.lines() { + let trimmed = line.trim(); + if trimmed == "failures:" { + in_failures = true; + continue; + } + if in_failures { + if trimmed.is_empty() || trimmed.starts_with("test result:") { + in_failures = false; + continue; + } + if !names.iter().any(|n: &String| n == trimmed) { + names.push(trimmed.to_string()); + } + } + } + names +} + +/// Human-readable label for the gate/step that produced a [`MergeResult`]. +fn failing_gate_label(result: &MergeResult) -> &'static str { + match result { + MergeResult::Success { .. } => "none", + MergeResult::Conflict { .. } => "git merge", + MergeResult::GateFailure { failure_kind, .. } => match failure_kind { + Some(GateFailureKind::Fmt) => "fmt", + Some(GateFailureKind::Lint) => "clippy", + Some(GateFailureKind::Test) => "tests", + Some(GateFailureKind::SourceMapCheck) => "source-map-check", + Some(GateFailureKind::ContentConflict) => "git merge", + Some(GateFailureKind::Build) => "build", + Some(GateFailureKind::Other) | None => "unknown", + }, + MergeResult::NoCommits { .. } => "no commits", + MergeResult::Other { .. } => "unknown", + } +} + +/// Human-readable label for the gate/step that produced a [`MergeFailureKind`]. +fn failing_gate_label_for_kind(kind: &MergeFailureKind) -> &'static str { + match kind { + MergeFailureKind::ConflictDetected(_) => "git merge", + MergeFailureKind::GatesFailed(_) => "quality gates", + MergeFailureKind::EmptyDiff => "empty diff", + MergeFailureKind::NoCommits => "no commits", + MergeFailureKind::Other(_) => "unknown", + } +} + +/// Build a condensed summary: a `Failing gate:` line, an optional +/// `Failing tests:` line (when any are parseable), and the last +/// [`SUMMARY_TAIL_LINES`] lines of `output`. +fn summarize(label: &str, output: &str) -> String { + let mut summary = format!("Failing gate: {label}\n"); + let test_names = extract_failing_test_names(output); + if !test_names.is_empty() { + summary.push_str(&format!("Failing tests: {}\n", test_names.join(", "))); + } + summary.push_str("--- output tail ---\n"); + summary.push_str(&truncate_gate_output(output, SUMMARY_TAIL_LINES)); + summary +} + +/// Summarize a completed [`MergeResult`] for MCP tool responses. +/// +/// Returns an empty string for [`MergeResult::Success`] — successful merges +/// carry no gate-failure summary. +pub fn summarize_merge_result(result: &MergeResult) -> String { + if matches!(result, MergeResult::Success { .. }) { + return String::new(); + } + summarize(failing_gate_label(result), result.output()) +} + +/// Summarize a [`MergeFailureKind`] for chat notifications, so the same +/// bounded shaping (label + parsed test names + output tail) reaches the +/// room instead of the raw gate/conflict output that `display_reason` +/// embeds verbatim. +pub fn summarize_merge_failure_kind(kind: &MergeFailureKind) -> String { + summarize(failing_gate_label_for_kind(kind), &kind.to_gate_output()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::gates::GateFailureKind; + + // ── extract_failing_test_names ──────────────────────────────────────────── + + #[test] + fn extract_failing_test_names_single_failures_section() { + let output = "running 3 tests\ntest foo::bar ... FAILED\ntest foo::baz ... ok\n\n\ + failures:\n foo::bar\n\ntest result: FAILED. 1 passed; 1 failed; 0 ignored\n"; + assert_eq!(extract_failing_test_names(output), vec!["foo::bar"]); + } + + #[test] + fn extract_failing_test_names_dedupes_across_sections() { + // cargo test prints the failing list once mid-output and again in the + // final "failures:" summary block; both must collapse to one entry. + let output = "failures:\n foo::bar\n\nfailures:\n foo::bar\n\ntest result: FAILED.\n"; + assert_eq!(extract_failing_test_names(output), vec!["foo::bar"]); + } + + #[test] + fn extract_failing_test_names_multiple_names() { + let output = "failures:\n a::one\n b::two\n\ntest result: FAILED.\n"; + assert_eq!(extract_failing_test_names(output), vec!["a::one", "b::two"]); + } + + #[test] + fn extract_failing_test_names_empty_when_no_failures_section() { + let output = "test result: ok. 3 passed; 0 failed\n"; + assert!(extract_failing_test_names(output).is_empty()); + } + + #[test] + fn extract_failing_test_names_empty_on_build_error_output() { + let output = "error[E0063]: missing field `plan` in initializer of `Stage`\n"; + assert!(extract_failing_test_names(output).is_empty()); + } + + // ── summarize_merge_result ───────────────────────────────────────────────── + + #[test] + fn summarize_merge_result_success_is_empty() { + let result = MergeResult::Success { + conflicts_resolved: false, + conflict_details: None, + gate_output: "all good".to_string(), + }; + assert_eq!(summarize_merge_result(&result), ""); + } + + #[test] + fn summarize_merge_result_build_error_names_the_gate() { + let result = MergeResult::GateFailure { + output: "error[E0063]: missing field `plan`".to_string(), + failure_kind: Some(GateFailureKind::Build), + }; + let summary = summarize_merge_result(&result); + assert!(summary.starts_with("Failing gate: build\n")); + assert!(summary.contains("error[E0063]")); + assert!(!summary.contains("Failing tests:")); + } + + #[test] + fn summarize_merge_result_test_failure_includes_names() { + let output = "running 1 test\ntest foo::bar ... FAILED\n\n\ + failures:\n foo::bar\n\ntest result: FAILED. 0 passed; 1 failed\n"; + let result = MergeResult::GateFailure { + output: output.to_string(), + failure_kind: Some(GateFailureKind::Test), + }; + let summary = summarize_merge_result(&result); + assert!(summary.starts_with("Failing gate: tests\n")); + assert!(summary.contains("Failing tests: foo::bar\n")); + } + + #[test] + fn summarize_merge_result_conflict_names_git_merge() { + let result = MergeResult::Conflict { + details: Some("CONFLICT in src/lib.rs".to_string()), + output: "merge failed".to_string(), + }; + let summary = summarize_merge_result(&result); + assert!(summary.starts_with("Failing gate: git merge\n")); + assert!(summary.contains("merge failed")); + } + + #[test] + fn summarize_merge_result_truncates_long_output() { + let lines: Vec = (1..=100).map(|i| format!("line{i}")).collect(); + let output = lines.join("\n"); + let result = MergeResult::GateFailure { + output, + failure_kind: Some(GateFailureKind::Test), + }; + let summary = summarize_merge_result(&result); + assert!(summary.contains("output truncated")); + assert!(summary.contains("line100")); + assert!( + !summary.contains("line1\n"), + "line1 should have been dropped by the tail" + ); + } + + // ── summarize_merge_failure_kind ─────────────────────────────────────────── + + #[test] + fn summarize_merge_failure_kind_gates_failed() { + let kind = MergeFailureKind::GatesFailed("test result: FAILED. 1 failed".to_string()); + let summary = summarize_merge_failure_kind(&kind); + assert!(summary.starts_with("Failing gate: quality gates\n")); + assert!(summary.contains("test result: FAILED")); + } + + #[test] + fn summarize_merge_failure_kind_does_not_include_raw_prefix_sentence() { + // display_reason() would prefix "Quality gates failed: "; the summary + // must not duplicate that human-sentence framing. + let kind = MergeFailureKind::GatesFailed("boom".to_string()); + let summary = summarize_merge_failure_kind(&kind); + assert!(!summary.contains("Quality gates failed:")); + } +}