Skip to main content

terraphim_orchestrator/
compound.rs

1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use tokio::task::JoinSet;
5use tracing::{debug, info, warn};
6use uuid::Uuid;
7
8use terraphim_types::{FindingCategory, FindingSeverity, ReviewAgentOutput, ReviewFinding};
9
10use crate::config::CompoundReviewConfig;
11use crate::error::OrchestratorError;
12use crate::scope::{WorktreeManager, WORKTREE_REVIEW_PREFIX};
13
14// Embed prompt templates at compile time to avoid CWD-dependent file loading.
15// The ADF binary may run from /opt/ai-dark-factory/ but templates live in the
16// source tree. Embedding eliminates the path resolution issue entirely.
17const PROMPT_SECURITY: &str = include_str!("../prompts/review-security.md");
18const PROMPT_ARCHITECTURE: &str = include_str!("../prompts/review-architecture.md");
19const PROMPT_PERFORMANCE: &str = include_str!("../prompts/review-performance.md");
20const PROMPT_QUALITY: &str = include_str!("../prompts/review-quality.md");
21const PROMPT_DOMAIN: &str = include_str!("../prompts/review-domain.md");
22const PROMPT_DESIGN_QUALITY: &str = include_str!("../prompts/review-design-quality.md");
23
24/// Definition of a single review group (1 agent per group).
25#[derive(Debug, Clone)]
26pub struct ReviewGroupDef {
27    /// Name of the agent (e.g., "security-sentinel").
28    pub agent_name: String,
29    /// Category of findings this agent produces.
30    pub category: FindingCategory,
31    /// LLM tier to use (e.g., "Quick", "Deep").
32    pub llm_tier: String,
33    /// CLI tool to invoke (e.g., "opencode", "claude").
34    pub cli_tool: String,
35    /// Optional model override.
36    pub model: Option<String>,
37    /// Path to prompt template file (retained for logging/debug).
38    pub prompt_template: String,
39    /// Embedded prompt content (compile-time via include_str).
40    pub prompt_content: &'static str,
41    /// Whether this agent only runs on visual/design changes.
42    pub visual_only: bool,
43    /// Persona identity for this review agent (e.g., "Vigil", "Carthos").
44    pub persona: Option<String>,
45}
46
47impl ReviewGroupDef {
48    /// Load the prompt template content from file.
49    pub fn prompt(&self) -> &str {
50        self.prompt_content
51    }
52}
53
54/// Configuration for the review swarm.
55#[derive(Debug, Clone)]
56pub struct SwarmConfig {
57    /// Review group definitions (6 groups).
58    pub groups: Vec<ReviewGroupDef>,
59    /// Timeout for agent execution.
60    pub timeout: Duration,
61    /// Root directory for worktrees.
62    pub worktree_root: PathBuf,
63    /// Path to the git repository.
64    pub repo_path: PathBuf,
65    /// Base branch for comparison.
66    pub base_branch: String,
67    /// Maximum number of concurrent agents.
68    pub max_concurrent_agents: usize,
69    /// Whether to create PRs with findings.
70    pub create_prs: bool,
71}
72
73impl SwarmConfig {
74    /// Create a SwarmConfig from CompoundReviewConfig and add default groups.
75    pub fn from_compound_config(config: &CompoundReviewConfig) -> Self {
76        let mut groups = default_groups();
77
78        // Override cli_tool and model from CompoundReviewConfig when present.
79        if let Some(ref cli_tool) = config.cli_tool {
80            for group in &mut groups {
81                group.cli_tool = cli_tool.clone();
82            }
83        }
84        if let Some(ref model) = config.model {
85            // If provider is also set and CLI is opencode, compose provider/model
86            let composed = if let Some(ref provider) = config.provider {
87                let cli_tool_name = config.cli_tool.as_deref().unwrap_or("");
88                let cli_name = std::path::Path::new(cli_tool_name)
89                    .file_name()
90                    .and_then(|n| n.to_str())
91                    .unwrap_or(cli_tool_name);
92                if cli_name == "opencode" {
93                    format!("{}/{}", provider, model)
94                } else {
95                    model.clone()
96                }
97            } else {
98                model.clone()
99            };
100            for group in &mut groups {
101                group.model = Some(composed.clone());
102            }
103        }
104
105        Self {
106            groups,
107            timeout: Duration::from_secs(config.max_duration_secs),
108            worktree_root: config.worktree_root.clone(),
109            repo_path: config.repo_path.clone(),
110            base_branch: config.base_branch.clone(),
111            max_concurrent_agents: config.max_concurrent_agents,
112            create_prs: config.create_prs,
113        }
114    }
115
116    /// Create a SwarmConfig from CompoundReviewConfig with no review groups.
117    /// Useful for testing orchestrator lifecycle without spawning agents.
118    pub fn from_compound_config_empty(config: &CompoundReviewConfig) -> Self {
119        Self {
120            groups: vec![],
121            timeout: Duration::from_secs(300),
122            worktree_root: config.worktree_root.clone(),
123            repo_path: config.repo_path.clone(),
124            base_branch: config.base_branch.clone(),
125            max_concurrent_agents: config.max_concurrent_agents,
126            create_prs: config.create_prs,
127        }
128    }
129}
130
131/// Result of a compound review cycle.
132#[derive(Debug, Clone)]
133pub struct CompoundReviewResult {
134    /// Correlation ID for this review run.
135    pub correlation_id: Uuid,
136    /// All findings from all agents (deduplicated).
137    pub findings: Vec<ReviewFinding>,
138    /// Individual agent outputs.
139    pub agent_outputs: Vec<ReviewAgentOutput>,
140    /// Overall pass/fail status.
141    pub pass: bool,
142    /// Duration of the review.
143    pub duration: Duration,
144    /// Number of agents that ran.
145    pub agents_run: usize,
146    /// Number of agents that failed.
147    pub agents_failed: usize,
148}
149
150impl CompoundReviewResult {
151    /// Format a structured markdown summary suitable for posting as a Gitea comment.
152    pub fn format_report(&self) -> String {
153        let verdict = if self.pass { "✅ PASS" } else { "❌ NO-GO" };
154        let duration_secs = self.duration.as_secs();
155
156        let mut report = "## Compound Review\n\n".to_string();
157        report.push_str(&format!(
158            "**Verdict: {}** | Duration: {}s | Agents: {} ({} failed)\n\n",
159            verdict, duration_secs, self.agents_run, self.agents_failed
160        ));
161
162        // Findings table
163        if !self.findings.is_empty() {
164            report.push_str(&format!("### Findings ({})\n\n", self.findings.len()));
165            report.push_str("| Severity | File | Finding | Conf |\n");
166            report.push_str("|----------|------|---------|------|\n");
167            for f in &self.findings {
168                let sev = format!("{:?}", f.severity);
169                let file_loc = if !f.file.is_empty() {
170                    if f.line > 0 {
171                        format!("{}:{}", f.file, f.line)
172                    } else {
173                        f.file.clone()
174                    }
175                } else {
176                    "-".to_string()
177                };
178                // Truncate finding text
179                let finding_text = if f.finding.len() > 120 {
180                    format!("{}...", &f.finding[..117])
181                } else {
182                    f.finding.clone()
183                };
184                report.push_str(&format!(
185                    "| {} | {} | {} | {:.0}% |\n",
186                    sev,
187                    file_loc,
188                    finding_text,
189                    f.confidence * 100.0
190                ));
191            }
192            report.push('\n');
193        } else {
194            report.push_str("**No findings.**\n\n");
195        }
196
197        // Per-agent summary
198        report.push_str("### Per-Agent Summary\n\n");
199        for output in &self.agent_outputs {
200            let status = if output.pass { "✅" } else { "❌" };
201            report.push_str(&format!(
202                "- {} {}: {} finding(s) — {}\n",
203                status,
204                output.agent,
205                output.findings.len(),
206                output.summary
207            ));
208        }
209
210        report
211    }
212
213    /// Extract CRITICAL and HIGH findings suitable for issue filing.
214    pub fn actionable_findings(&self) -> Vec<&ReviewFinding> {
215        self.findings
216            .iter()
217            .filter(|f| {
218                matches!(
219                    f.severity,
220                    FindingSeverity::Critical | FindingSeverity::High
221                )
222            })
223            .collect()
224    }
225}
226
227/// Nightly compound review workflow with 6-agent swarm.
228///
229/// Dispatches review agents in parallel, collects findings,
230/// and optionally creates PRs with results.
231#[derive(Debug)]
232pub struct CompoundReviewWorkflow {
233    config: SwarmConfig,
234    worktree_manager: WorktreeManager,
235}
236
237impl CompoundReviewWorkflow {
238    /// Create a new compound review workflow from swarm config.
239    pub fn new(config: SwarmConfig) -> Self {
240        let worktree_manager = WorktreeManager::with_base(&config.repo_path, &config.worktree_root);
241        Self {
242            config,
243            worktree_manager,
244        }
245    }
246
247    /// Create from CompoundReviewConfig (legacy compatibility).
248    pub fn from_compound_config(config: CompoundReviewConfig) -> Self {
249        let swarm_config = SwarmConfig::from_compound_config(&config);
250        Self::new(swarm_config)
251    }
252
253    /// Borrow the inner [`WorktreeManager`].
254    ///
255    /// Layer 2 (epic #1567, issue #1570) calls
256    /// `worktree_manager().sweep_stale(...)` from
257    /// `AgentOrchestrator::new` to reconcile stale `review-*` residue
258    /// left behind by SIGKILL / OOM before any tick thread runs.
259    /// Production code outside the startup sweep should prefer the
260    /// higher-level workflow methods.
261    pub fn worktree_manager(&self) -> &WorktreeManager {
262        &self.worktree_manager
263    }
264
265    /// Run a full compound review cycle.
266    ///
267    /// 1. Get changed files between git_ref and base_ref
268    /// 2. Filter groups based on visual changes
269    /// 3. Spawn agents in parallel
270    /// 4. Collect results with timeout
271    /// 5. Deduplicate findings
272    /// 6. Return structured result
273    pub async fn run(
274        &self,
275        git_ref: &str,
276        base_ref: &str,
277    ) -> Result<CompoundReviewResult, OrchestratorError> {
278        let start = Instant::now();
279        let correlation_id = Uuid::new_v4();
280
281        info!(
282            correlation_id = %correlation_id,
283            git_ref = %git_ref,
284            base_ref = %base_ref,
285            "starting compound review swarm"
286        );
287
288        // Get changed files
289        let changed_files = self.get_changed_files(git_ref, base_ref).await?;
290        debug!(count = changed_files.len(), "found changed files");
291
292        // Filter groups based on visual changes
293        let has_visual = has_visual_changes(&changed_files);
294        let active_groups: Vec<&ReviewGroupDef> = self
295            .config
296            .groups
297            .iter()
298            .filter(|g| !g.visual_only || has_visual)
299            .collect();
300
301        info!(
302            total_groups = self.config.groups.len(),
303            active_groups = active_groups.len(),
304            has_visual_changes = has_visual,
305            "filtered review groups"
306        );
307
308        // Create worktree for this review.
309        //
310        // Drop-ordering invariant (epic #1567, Layer 1, issue #1569):
311        //
312        // `guard` MUST be declared BEFORE `tasks`. Locals drop in
313        // reverse declaration order, so:
314        //   1. `tasks: JoinSet` drops FIRST, aborting every spawned
315        //      agent task. Because `Command::kill_on_drop(true)` is
316        //      set in `run_single_agent`, each task's Drop kills its
317        //      child subprocess synchronously (SIGKILL via the
318        //      `tokio::process::Child` Drop wired through the kill-
319        //      on-drop bit).
320        //   2. `guard: WorktreeGuard` drops LAST, running
321        //      `git -C <repo> worktree remove --force <path>` (with
322        //      a filesystem fallback). The git admin entry at
323        //      `<repo>/.git/worktrees/<name>` is reconciled along
324        //      with the directory.
325        //
326        // Inverting this order recreates the worktree storm race:
327        // the guard would remove the worktree while subprocesses
328        // still hold open file handles into it, then JoinSet abort
329        // would orphan those subprocesses against a torn-down git
330        // admin registry. The cancellation property test in
331        // `tests/compound_cancellation_test.rs` encodes this
332        // invariant; do NOT reorder these two locals.
333        let worktree_name = format!("{}{}", WORKTREE_REVIEW_PREFIX, correlation_id);
334        let guard = self
335            .worktree_manager
336            .create_worktree(&worktree_name, git_ref)
337            .await
338            .map_err(|e| {
339                OrchestratorError::CompoundReviewFailed(format!("failed to create worktree: {}", e))
340            })?;
341        let worktree_path = guard.path().to_path_buf();
342
343        let mut tasks: JoinSet<AgentResult> = JoinSet::new();
344
345        // Spawn agents in parallel via JoinSet so that dropping the
346        // parent future aborts every child task before the guard's
347        // synchronous `git worktree remove` runs.
348        let mut spawned_count = 0;
349        for group in active_groups {
350            let group = group.clone();
351            let worktree_path_task = worktree_path.clone();
352            let changed_files = changed_files.clone();
353            let timeout = self.config.timeout;
354            let cli_tool = group.cli_tool.clone();
355
356            tasks.spawn(async move {
357                run_single_agent(
358                    &group,
359                    &worktree_path_task,
360                    &changed_files,
361                    correlation_id,
362                    timeout,
363                    &cli_tool,
364                )
365                .await
366            });
367            spawned_count += 1;
368        }
369
370        // Collect results with deadline-based timeout.
371        let mut agent_outputs = Vec::new();
372        let mut failed_count = 0;
373        let collect_deadline =
374            tokio::time::Instant::now() + self.config.timeout + Duration::from_secs(10);
375
376        loop {
377            match tokio::time::timeout_at(collect_deadline, tasks.join_next()).await {
378                Ok(Some(Ok(result))) => match result {
379                    AgentResult::Success(output) => {
380                        info!(agent = %output.agent, findings = output.findings.len(), "agent completed");
381                        agent_outputs.push(output);
382                    }
383                    AgentResult::Failed { agent_name, reason } => {
384                        warn!(agent = %agent_name, error = %reason, "agent failed");
385                        failed_count += 1;
386                        agent_outputs.push(ReviewAgentOutput {
387                            agent: agent_name,
388                            findings: vec![],
389                            summary: format!("Agent failed: {}", reason),
390                            pass: false,
391                        });
392                    }
393                },
394                Ok(Some(Err(join_err))) => {
395                    warn!(error = %join_err, "agent task aborted or panicked");
396                    failed_count += 1;
397                }
398                Ok(None) => break, // JoinSet drained, all tasks finished
399                Err(_) => {
400                    warn!("collection deadline exceeded, using partial results");
401                    break;
402                }
403            }
404        }
405
406        // No explicit cleanup: `guard` drops at end of scope and
407        // invokes `git worktree remove --force` synchronously.
408        // Suppress the unused-variable lint -- the local exists for
409        // its Drop side effect; it is also borrowed above via
410        // `guard.path()`.
411        let _ = &guard;
412
413        // Collect all findings and deduplicate
414        let all_findings: Vec<ReviewFinding> = agent_outputs
415            .iter()
416            .flat_map(|o| o.findings.clone())
417            .collect();
418        let deduplicated = terraphim_types::deduplicate_findings(all_findings);
419
420        // Determine overall pass/fail
421        let pass = agent_outputs.iter().all(|o| o.pass) && failed_count == 0;
422
423        let duration = start.elapsed();
424        info!(
425            correlation_id = %correlation_id,
426            agents_run = spawned_count,
427            agents_failed = failed_count,
428            total_findings = deduplicated.len(),
429            pass = %pass,
430            duration = ?duration,
431            "compound review completed"
432        );
433
434        Ok(CompoundReviewResult {
435            correlation_id,
436            findings: deduplicated,
437            agent_outputs,
438            pass,
439            duration,
440            agents_run: spawned_count,
441            agents_failed: failed_count,
442        })
443    }
444
445    /// Get the default review groups (6 groups).
446    pub fn default_groups() -> Vec<ReviewGroupDef> {
447        default_groups()
448    }
449
450    /// Check if there are visual changes in the changed files.
451    pub fn has_visual_changes(changed_files: &[String]) -> bool {
452        has_visual_changes(changed_files)
453    }
454
455    /// Extract ReviewAgentOutput from agent stdout.
456    pub fn extract_review_output(
457        stdout: &str,
458        agent_name: &str,
459        category: FindingCategory,
460    ) -> ReviewAgentOutput {
461        extract_review_output(stdout, agent_name, category)
462    }
463
464    /// Get list of changed files between two git refs.
465    async fn get_changed_files(
466        &self,
467        git_ref: &str,
468        base_ref: &str,
469    ) -> Result<Vec<String>, OrchestratorError> {
470        let output = tokio::process::Command::new("git")
471            .args([
472                "-C",
473                self.config.repo_path.to_str().unwrap_or("."),
474                "diff",
475                "--name-only",
476                base_ref,
477                git_ref,
478            ])
479            .env_remove("GIT_INDEX_FILE")
480            .output()
481            .await
482            .map_err(|e| {
483                OrchestratorError::CompoundReviewFailed(format!("git diff failed: {}", e))
484            })?;
485
486        if !output.status.success() {
487            let stderr = String::from_utf8_lossy(&output.stderr);
488            return Err(OrchestratorError::CompoundReviewFailed(format!(
489                "git diff returned non-zero: {}",
490                stderr
491            )));
492        }
493
494        let stdout = String::from_utf8_lossy(&output.stdout);
495        let files: Vec<String> = stdout
496            .lines()
497            .filter(|line| !line.trim().is_empty())
498            .map(|line| line.to_string())
499            .collect();
500
501        Ok(files)
502    }
503
504    /// Check if the compound review is in dry-run mode.
505    pub fn is_dry_run(&self) -> bool {
506        !self.config.create_prs
507    }
508}
509
510/// Result from a single agent execution.
511enum AgentResult {
512    Success(ReviewAgentOutput),
513    Failed { agent_name: String, reason: String },
514}
515
516/// Run a single review agent.
517async fn run_single_agent(
518    group: &ReviewGroupDef,
519    worktree_path: &Path,
520    changed_files: &[String],
521    _correlation_id: Uuid,
522    timeout: Duration,
523    cli_tool: &str,
524) -> AgentResult {
525    let agent_name = &group.agent_name;
526
527    // Use embedded prompt content (no filesystem access needed)
528    let prompt = group.prompt_content;
529
530    // Build the command with CLI-specific argument formatting
531    let mut cmd = tokio::process::Command::new(cli_tool);
532
533    // Ensure that dropping the `Child` handle kills the underlying
534    // subprocess (epic #1567, Layer 1, issue #1569). Without this,
535    // aborting the JoinSet wrapping this task drops `Child` but does
536    // not signal the OS process, leaving zombie agents running until
537    // their own `cmd.output()` timeout (up to 30 minutes in
538    // production). Combined with the JoinSet abort, kill_on_drop
539    // gives cooperative-then-forceful shutdown on cancellation.
540    cmd.kill_on_drop(true);
541
542    // Determine CLI name for argument format selection
543    let cli_name = std::path::Path::new(cli_tool)
544        .file_name()
545        .and_then(|n| n.to_str())
546        .unwrap_or(cli_tool);
547
548    match cli_name {
549        "opencode" => {
550            cmd.arg("run").arg("--format").arg("json");
551            if let Some(ref model) = group.model {
552                cmd.arg("-m").arg(model);
553            }
554            cmd.arg(prompt);
555        }
556        "claude" | "claude-code" => {
557            cmd.arg("-p").arg(prompt);
558            if let Some(ref model) = group.model {
559                cmd.arg("--model").arg(model);
560            }
561        }
562        "codex" => {
563            cmd.arg("exec").arg("--full-auto");
564            if let Some(ref model) = group.model {
565                cmd.arg("-m").arg(model);
566            }
567            cmd.arg(prompt);
568        }
569        _ => {
570            cmd.arg(prompt);
571        }
572    }
573    cmd.current_dir(worktree_path);
574
575    // Add changed files as arguments
576    for file in changed_files {
577        cmd.arg(file);
578    }
579
580    debug!(
581        agent = %agent_name,
582        command = ?cmd,
583        "spawning review agent"
584    );
585
586    // Run with timeout
587    let result = tokio::time::timeout(timeout, cmd.output()).await;
588
589    match result {
590        Ok(Ok(output)) => {
591            let stdout = String::from_utf8_lossy(&output.stdout);
592            let review_output = extract_review_output(&stdout, agent_name, group.category);
593            AgentResult::Success(review_output)
594        }
595        Ok(Err(e)) => AgentResult::Failed {
596            agent_name: agent_name.clone(),
597            reason: format!("command execution failed: {}", e),
598        },
599        Err(_) => AgentResult::Failed {
600            agent_name: agent_name.clone(),
601            reason: "timeout exceeded".to_string(),
602        },
603    }
604}
605
606/// Extract ReviewAgentOutput from agent stdout.
607/// Scans stdout for JSON matching ReviewAgentOutput schema.
608/// Graceful fallback: empty output with pass: true if no valid JSON found.
609fn extract_review_output(
610    stdout: &str,
611    agent_name: &str,
612    category: FindingCategory,
613) -> ReviewAgentOutput {
614    // Step 1: Unwrap opencode JSON protocol if present.
615    // opencode --format json wraps all output as:
616    //   {"type":"text","part":{"type":"text","text":"..."}}
617    // We extract the inner text content and concatenate it.
618    let unwrapped = unwrap_opencode_protocol(stdout);
619
620    // Step 2: Scan for ReviewAgentOutput JSON
621    for line in unwrapped.lines() {
622        let trimmed = line.trim();
623        if trimmed.is_empty() {
624            continue;
625        }
626
627        // Try to parse as ReviewAgentOutput directly
628        if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(trimmed) {
629            return output;
630        }
631
632        // Try to parse inside markdown code blocks
633        if trimmed.starts_with("```json") {
634            let json_content = trimmed
635                .strip_prefix("```json")
636                .and_then(|s| s.strip_suffix("```"))
637                .or_else(|| {
638                    trimmed
639                        .strip_prefix("```json")
640                        .map(|s| s.trim_end_matches("```"))
641                });
642
643            if let Some(content) = json_content {
644                let clean_content = content.trim();
645                if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(clean_content) {
646                    return output;
647                }
648            }
649        }
650    }
651
652    // Step 3: Fallback — try to parse entire unwrapped output as JSON
653    if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(&unwrapped) {
654        return output;
655    }
656
657    // Step 4: Heuristic — if output contains finding-like keywords, create synthetic findings
658    let mut findings = vec![];
659    let _lower = unwrapped.to_lowercase();
660    for line in unwrapped.lines() {
661        let line_lower = line.to_lowercase();
662        if line_lower.contains("critical")
663            || line_lower.contains("vulnerability")
664            || line_lower.contains("cve-")
665            || line_lower.contains("rustsec-")
666        {
667            let severity = if line_lower.contains("critical") {
668                FindingSeverity::Critical
669            } else if line_lower.contains("high") {
670                FindingSeverity::High
671            } else {
672                FindingSeverity::Medium
673            };
674            findings.push(ReviewFinding {
675                file: String::new(),
676                line: 0,
677                severity,
678                category,
679                finding: line.trim().to_string(),
680                confidence: 0.7,
681                suggestion: None,
682            });
683        }
684    }
685
686    if !findings.is_empty() {
687        let count = findings.len();
688        return ReviewAgentOutput {
689            agent: agent_name.to_string(),
690            findings,
691            summary: format!("Extracted {} findings from unstructured output", count),
692            pass: false,
693        };
694    }
695
696    // No parseable output
697    ReviewAgentOutput {
698        agent: agent_name.to_string(),
699        findings: vec![],
700        summary: format!(
701            "No structured output found in agent response. Output length: {} chars",
702            unwrapped.len()
703        ),
704        pass: false,
705    }
706}
707
708/// Unwrap opencode JSON protocol lines into plain text.
709///
710/// opencode `--format json` outputs lines like:
711///   {"type":"text","part":{"type":"text","text":"actual content here"}}
712///   {"type":"tool_use","part":{"tool":"write",...}}
713///
714/// This function extracts all `text` content from these protocol messages
715/// and returns the concatenated plain text.
716fn unwrap_opencode_protocol(stdout: &str) -> String {
717    use serde_json::Value;
718
719    let mut result = String::new();
720    let mut has_protocol = false;
721
722    for line in stdout.lines() {
723        let trimmed = line.trim();
724        if trimmed.is_empty() {
725            continue;
726        }
727
728        if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
729            // opencode protocol: {"type":"text","part":{"type":"text","text":"..."}}
730            if val.is_object() {
731                if let Some(text) = val
732                    .get("part")
733                    .and_then(|p| p.get("text"))
734                    .and_then(|t| t.as_str())
735                {
736                    has_protocol = true;
737                    result.push_str(text);
738                    result.push('\n');
739                    continue;
740                }
741                // Also check direct "text" field
742                if let Some(text) = val.get("text").and_then(|t| t.as_str()) {
743                    has_protocol = true;
744                    result.push_str(text);
745                    result.push('\n');
746                    continue;
747                }
748                // Format other opencode protocol messages (tool_use, tool_result, etc.)
749                // as brief summaries instead of keeping raw JSON. Raw payloads contain
750                // file content that triggers false positives in the heuristic scanner.
751                if let Some(msg_type) = val.get("type").and_then(|t| t.as_str()) {
752                    has_protocol = true;
753                    let tool_name = val
754                        .get("part")
755                        .and_then(|p| p.get("tool"))
756                        .and_then(|t| t.as_str())
757                        .unwrap_or("unknown");
758                    let status = val
759                        .get("part")
760                        .and_then(|p| p.get("state"))
761                        .and_then(|s| s.get("status"))
762                        .and_then(|s| s.as_str())
763                        .unwrap_or("");
764                    let input_path = val
765                        .get("part")
766                        .and_then(|p| p.get("state"))
767                        .and_then(|s| s.get("input"))
768                        .and_then(|i| {
769                            i.get("filePath")
770                                .or_else(|| i.get("path"))
771                                .or_else(|| i.get("command"))
772                        })
773                        .and_then(|v| v.as_str())
774                        .unwrap_or("");
775                    if input_path.is_empty() {
776                        result.push_str(&format!("[{}: {}]\n", msg_type, tool_name));
777                    } else {
778                        result.push_str(&format!(
779                            "[{}: {} {} {}]\n",
780                            msg_type, tool_name, input_path, status
781                        ));
782                    }
783                    continue;
784                }
785            }
786        }
787
788        // Not protocol JSON — keep as-is
789        result.push_str(trimmed);
790        result.push('\n');
791    }
792
793    if has_protocol {
794        result
795    } else {
796        stdout.to_string()
797    }
798}
799
800/// Check if there are visual/design changes in the changed files.
801fn has_visual_changes(changed_files: &[String]) -> bool {
802    let visual_patterns = get_visual_patterns();
803
804    for file in changed_files {
805        for pattern in &visual_patterns {
806            if glob_matches(file, pattern) {
807                return true;
808            }
809        }
810    }
811
812    false
813}
814
815/// Get visual file detection patterns.
816fn get_visual_patterns() -> Vec<&'static str> {
817    vec![
818        "*.css",
819        "*.scss",
820        "tokens.*",
821        "DESIGN.md",
822        "*.svelte",
823        "*.tsx",
824        "*.vue",
825        "src/components/*",
826        "src/ui/*",
827        "design-system/*",
828    ]
829}
830
831/// Check if a file path matches a glob pattern.
832/// Supports: *.ext, prefix.*, directory/*, exact matches
833fn glob_matches(file: &str, pattern: &str) -> bool {
834    // Exact match
835    if file == pattern {
836        return true;
837    }
838
839    // Extension pattern: *.css
840    if pattern.starts_with("*.") {
841        let ext = &pattern[1..]; // .css
842        if file.ends_with(ext) {
843            return true;
844        }
845    }
846
847    // Prefix pattern with wildcard: tokens.*
848    if pattern.ends_with(".*") {
849        let prefix = &pattern[..pattern.len() - 1]; // tokens.
850        if file.starts_with(prefix) {
851            return true;
852        }
853    }
854
855    // Directory pattern: src/components/*
856    if pattern.ends_with("/*") {
857        let prefix = &pattern[..pattern.len() - 1]; // src/components/
858        if file.starts_with(prefix) {
859            return true;
860        }
861    }
862
863    // Prefix pattern without wildcard
864    if pattern.ends_with('/') && file.starts_with(pattern) {
865        return true;
866    }
867
868    false
869}
870
871/// Get the default 6 review groups.
872fn default_groups() -> Vec<ReviewGroupDef> {
873    vec![
874        ReviewGroupDef {
875            agent_name: "security-sentinel".to_string(),
876            category: FindingCategory::Security,
877            llm_tier: "Quick".to_string(),
878            cli_tool: "opencode".to_string(),
879            model: None,
880            prompt_template: "crates/terraphim_orchestrator/prompts/review-security.md".to_string(),
881            prompt_content: PROMPT_SECURITY,
882            visual_only: false,
883            persona: Some("Vigil".to_string()),
884        },
885        ReviewGroupDef {
886            agent_name: "architecture-strategist".to_string(),
887            category: FindingCategory::Architecture,
888            llm_tier: "Deep".to_string(),
889            cli_tool: "claude".to_string(),
890            model: None,
891            prompt_template: "crates/terraphim_orchestrator/prompts/review-architecture.md"
892                .to_string(),
893            prompt_content: PROMPT_ARCHITECTURE,
894            visual_only: false,
895            persona: Some("Carthos".to_string()),
896        },
897        ReviewGroupDef {
898            agent_name: "performance-oracle".to_string(),
899            category: FindingCategory::Performance,
900            llm_tier: "Deep".to_string(),
901            cli_tool: "claude".to_string(),
902            model: None,
903            prompt_template: "crates/terraphim_orchestrator/prompts/review-performance.md"
904                .to_string(),
905            prompt_content: PROMPT_PERFORMANCE,
906            visual_only: false,
907            persona: Some("Ferrox".to_string()),
908        },
909        ReviewGroupDef {
910            agent_name: "rust-reviewer".to_string(),
911            category: FindingCategory::Quality,
912            llm_tier: "Deep".to_string(),
913            cli_tool: "claude".to_string(),
914            model: None,
915            prompt_template: "crates/terraphim_orchestrator/prompts/review-quality.md".to_string(),
916            prompt_content: PROMPT_QUALITY,
917            visual_only: false,
918            persona: Some("Ferrox".to_string()),
919        },
920        ReviewGroupDef {
921            agent_name: "domain-model-reviewer".to_string(),
922            category: FindingCategory::Domain,
923            llm_tier: "Quick".to_string(),
924            cli_tool: "opencode".to_string(),
925            model: None,
926            prompt_template: "crates/terraphim_orchestrator/prompts/review-domain.md".to_string(),
927            prompt_content: PROMPT_DOMAIN,
928            visual_only: false,
929            persona: Some("Carthos".to_string()),
930        },
931        ReviewGroupDef {
932            agent_name: "design-fidelity-reviewer".to_string(),
933            category: FindingCategory::DesignQuality,
934            llm_tier: "Deep".to_string(),
935            cli_tool: "claude".to_string(),
936            model: None,
937            prompt_template: "crates/terraphim_orchestrator/prompts/review-design-quality.md"
938                .to_string(),
939            prompt_content: PROMPT_DESIGN_QUALITY,
940            visual_only: true,
941            persona: Some("Lux".to_string()),
942        },
943    ]
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use terraphim_types::FindingSeverity;
950
951    // ==================== Visual File Detection Tests ====================
952
953    #[test]
954    fn test_visual_file_detection_css() {
955        let files = vec!["styles.css".to_string()];
956        assert!(has_visual_changes(&files));
957    }
958
959    #[test]
960    fn test_visual_file_detection_tsx() {
961        let files = vec!["src/components/Button.tsx".to_string()];
962        assert!(has_visual_changes(&files));
963    }
964
965    #[test]
966    fn test_visual_file_detection_design_md() {
967        let files = vec!["DESIGN.md".to_string()];
968        assert!(has_visual_changes(&files));
969    }
970
971    #[test]
972    fn test_visual_file_detection_rust_only() {
973        let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
974        assert!(!has_visual_changes(&files));
975    }
976
977    #[test]
978    fn test_visual_file_detection_component_dir() {
979        let files = vec!["src/components/mod.rs".to_string()];
980        assert!(has_visual_changes(&files));
981    }
982
983    #[test]
984    fn test_visual_file_detection_tokens() {
985        let files = vec!["tokens.json".to_string()];
986        assert!(has_visual_changes(&files));
987    }
988
989    // ==================== Extract Review Output Tests ====================
990
991    #[test]
992    fn test_extract_review_output_valid_json() {
993        let json = r#"{"agent":"test-agent","findings":[],"summary":"All good","pass":true}"#;
994        let output = extract_review_output(json, "test-agent", FindingCategory::Quality);
995        assert_eq!(output.agent, "test-agent");
996        assert!(output.pass);
997        assert_eq!(output.findings.len(), 0);
998    }
999
1000    #[test]
1001    fn test_extract_review_output_mixed_output() {
1002        let mixed = r#"Some log output here
1003{"agent":"test-agent","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 issue","pass":false}
1004More logs..."#;
1005        let output = extract_review_output(mixed, "test-agent", FindingCategory::Security);
1006        assert_eq!(output.agent, "test-agent");
1007        assert!(!output.pass);
1008        assert_eq!(output.findings.len(), 1);
1009        assert_eq!(output.findings[0].severity, FindingSeverity::High);
1010    }
1011
1012    #[test]
1013    fn test_extract_review_output_no_json() {
1014        let no_json = "Just some plain text output without JSON";
1015        let output = extract_review_output(no_json, "test-agent", FindingCategory::Quality);
1016        assert_eq!(output.agent, "test-agent");
1017        assert!(!output.pass); // Unparseable output treated as failure
1018        assert_eq!(output.findings.len(), 0);
1019    }
1020
1021    #[test]
1022    fn test_extract_review_output_markdown_code_block() {
1023        let markdown = r#"Here's my review:
1024
1025```json
1026{"agent":"test-agent","findings":[],"summary":"No issues","pass":true}
1027```
1028
1029Done!"#;
1030        let output = extract_review_output(markdown, "test-agent", FindingCategory::Quality);
1031        assert_eq!(output.agent, "test-agent");
1032        assert!(output.pass);
1033    }
1034
1035    // ==================== Default Groups Tests ====================
1036
1037    #[test]
1038    fn test_default_groups_count() {
1039        let groups = default_groups();
1040        assert_eq!(groups.len(), 6);
1041    }
1042
1043    #[test]
1044    fn test_default_groups_one_visual_only() {
1045        let groups = default_groups();
1046        let visual_only_count = groups.iter().filter(|g| g.visual_only).count();
1047        assert_eq!(visual_only_count, 1);
1048
1049        // Verify it's the design-fidelity-reviewer
1050        let visual_group = groups.iter().find(|g| g.visual_only).unwrap();
1051        assert_eq!(visual_group.agent_name, "design-fidelity-reviewer");
1052        assert_eq!(visual_group.category, FindingCategory::DesignQuality);
1053    }
1054
1055    #[test]
1056    fn test_default_groups_categories() {
1057        let groups = default_groups();
1058        let categories: Vec<_> = groups.iter().map(|g| g.category).collect();
1059
1060        assert!(categories.contains(&FindingCategory::Security));
1061        assert!(categories.contains(&FindingCategory::Architecture));
1062        assert!(categories.contains(&FindingCategory::Performance));
1063        assert!(categories.contains(&FindingCategory::Quality));
1064        assert!(categories.contains(&FindingCategory::Domain));
1065        assert!(categories.contains(&FindingCategory::DesignQuality));
1066    }
1067
1068    // ==================== Glob Matching Tests ====================
1069
1070    #[test]
1071    fn test_glob_matches_extension() {
1072        assert!(glob_matches("styles.css", "*.css"));
1073        assert!(glob_matches("app.scss", "*.scss"));
1074        assert!(glob_matches("Component.tsx", "*.tsx"));
1075        assert!(!glob_matches("main.rs", "*.css"));
1076    }
1077
1078    #[test]
1079    fn test_glob_matches_directory() {
1080        assert!(glob_matches("src/components/Button.rs", "src/components/*"));
1081        assert!(glob_matches("src/ui/mod.rs", "src/ui/*"));
1082        assert!(!glob_matches("src/main.rs", "src/components/*"));
1083    }
1084
1085    #[test]
1086    fn test_glob_matches_exact() {
1087        assert!(glob_matches("DESIGN.md", "DESIGN.md"));
1088        assert!(!glob_matches("README.md", "DESIGN.md"));
1089    }
1090
1091    #[test]
1092    fn test_glob_matches_design_system() {
1093        assert!(glob_matches("design-system/tokens.css", "design-system/*"));
1094        assert!(glob_matches(
1095            "design-system/components/button.css",
1096            "design-system/*"
1097        ));
1098    }
1099
1100    // ==================== Compound Review Integration Tests ====================
1101
1102    #[tokio::test]
1103    async fn test_compound_review_dry_run() {
1104        let swarm_config = SwarmConfig {
1105            groups: default_groups(),
1106            timeout: Duration::from_secs(60),
1107            worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1108            repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1109            base_branch: "main".to_string(),
1110            max_concurrent_agents: 3,
1111            create_prs: false,
1112        };
1113
1114        let workflow = CompoundReviewWorkflow::new(swarm_config);
1115        assert!(workflow.is_dry_run());
1116    }
1117
1118    #[tokio::test]
1119    async fn test_get_changed_files_real_repo() {
1120        let swarm_config = SwarmConfig {
1121            groups: default_groups(),
1122            timeout: Duration::from_secs(60),
1123            worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1124            repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1125            base_branch: "main".to_string(),
1126            max_concurrent_agents: 3,
1127            create_prs: false,
1128        };
1129
1130        let workflow = CompoundReviewWorkflow::new(swarm_config);
1131
1132        // Test with HEAD vs HEAD~1 (should work in any repo with history)
1133        let result = workflow.get_changed_files("HEAD", "HEAD~1").await;
1134
1135        // The result may fail if there's no history, but it should not panic
1136        match result {
1137            Ok(files) => {
1138                // If we have files, they should be valid paths
1139                for file in &files {
1140                    assert!(!file.is_empty());
1141                }
1142            }
1143            Err(_) => {
1144                // Error is acceptable in test environment without proper git setup
1145            }
1146        }
1147    }
1148
1149    #[test]
1150    fn test_swarm_config_from_compound_config() {
1151        let compound_config = CompoundReviewConfig {
1152            schedule: "0 2 * * *".to_string(),
1153            max_duration_secs: 1800,
1154            repo_path: PathBuf::from("/tmp/repo"),
1155            create_prs: false,
1156            worktree_root: PathBuf::from("/tmp/worktrees"),
1157            base_branch: "main".to_string(),
1158            max_concurrent_agents: 3,
1159            cli_tool: None,
1160            provider: None,
1161            model: None,
1162            ..Default::default()
1163        };
1164
1165        let swarm_config = SwarmConfig::from_compound_config(&compound_config);
1166
1167        assert_eq!(swarm_config.repo_path, PathBuf::from("/tmp/repo"));
1168        assert_eq!(swarm_config.worktree_root, PathBuf::from("/tmp/worktrees"));
1169        assert_eq!(swarm_config.base_branch, "main");
1170        assert_eq!(swarm_config.max_concurrent_agents, 3);
1171        assert!(!swarm_config.create_prs);
1172        assert_eq!(swarm_config.groups.len(), 6);
1173    }
1174
1175    #[test]
1176    fn test_compound_review_result_structure() {
1177        let result = CompoundReviewResult {
1178            correlation_id: Uuid::new_v4(),
1179            findings: vec![],
1180            agent_outputs: vec![],
1181            pass: true,
1182            duration: Duration::from_secs(10),
1183            agents_run: 6,
1184            agents_failed: 0,
1185        };
1186
1187        assert!(result.pass);
1188        assert_eq!(result.agents_run, 6);
1189        assert_eq!(result.agents_failed, 0);
1190    }
1191
1192    // ==================== Persona Identity Tests ====================
1193
1194    #[test]
1195    fn test_review_security_contains_vigil() {
1196        let prompt = include_str!("../prompts/review-security.md");
1197        assert!(
1198            prompt.contains("Vigil"),
1199            "review-security.md should contain 'Vigil'"
1200        );
1201        assert!(
1202            prompt.contains("Security Engineer"),
1203            "review-security.md should mention Security Engineer"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_review_architecture_contains_carthos() {
1209        let prompt = include_str!("../prompts/review-architecture.md");
1210        assert!(
1211            prompt.contains("Carthos"),
1212            "review-architecture.md should contain 'Carthos'"
1213        );
1214        assert!(
1215            prompt.contains("Domain Architect"),
1216            "review-architecture.md should mention Domain Architect"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_review_quality_contains_ferrox() {
1222        let prompt = include_str!("../prompts/review-quality.md");
1223        assert!(
1224            prompt.contains("Ferrox"),
1225            "review-quality.md should contain 'Ferrox'"
1226        );
1227        assert!(
1228            prompt.contains("Rust Engineer"),
1229            "review-quality.md should mention Rust Engineer"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_review_performance_contains_ferrox() {
1235        let prompt = include_str!("../prompts/review-performance.md");
1236        assert!(
1237            prompt.contains("Ferrox"),
1238            "review-performance.md should contain 'Ferrox'"
1239        );
1240        assert!(
1241            prompt.contains("Rust Engineer"),
1242            "review-performance.md should mention Rust Engineer"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_review_domain_contains_carthos() {
1248        let prompt = include_str!("../prompts/review-domain.md");
1249        assert!(
1250            prompt.contains("Carthos"),
1251            "review-domain.md should contain 'Carthos'"
1252        );
1253        assert!(
1254            prompt.contains("Domain Architect"),
1255            "review-domain.md should mention Domain Architect"
1256        );
1257    }
1258
1259    #[test]
1260    fn test_review_design_contains_lux() {
1261        let prompt = include_str!("../prompts/review-design-quality.md");
1262        assert!(
1263            prompt.contains("Lux"),
1264            "review-design-quality.md should contain 'Lux'"
1265        );
1266        assert!(
1267            prompt.contains("TypeScript Engineer"),
1268            "review-design-quality.md should mention TypeScript Engineer"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_default_groups_all_have_persona() {
1274        let groups = default_groups();
1275        for group in &groups {
1276            assert!(
1277                group.persona.is_some(),
1278                "Group '{}' should have a persona set",
1279                group.agent_name
1280            );
1281        }
1282
1283        // Verify specific persona mappings
1284        let vigil = groups
1285            .iter()
1286            .find(|g| g.agent_name == "security-sentinel")
1287            .unwrap();
1288        assert_eq!(vigil.persona.as_ref().unwrap(), "Vigil");
1289
1290        let carthos_arch = groups
1291            .iter()
1292            .find(|g| g.agent_name == "architecture-strategist")
1293            .unwrap();
1294        assert_eq!(carthos_arch.persona.as_ref().unwrap(), "Carthos");
1295
1296        let ferrox_perf = groups
1297            .iter()
1298            .find(|g| g.agent_name == "performance-oracle")
1299            .unwrap();
1300        assert_eq!(ferrox_perf.persona.as_ref().unwrap(), "Ferrox");
1301
1302        let ferrox_qual = groups
1303            .iter()
1304            .find(|g| g.agent_name == "rust-reviewer")
1305            .unwrap();
1306        assert_eq!(ferrox_qual.persona.as_ref().unwrap(), "Ferrox");
1307
1308        let carthos_domain = groups
1309            .iter()
1310            .find(|g| g.agent_name == "domain-model-reviewer")
1311            .unwrap();
1312        assert_eq!(carthos_domain.persona.as_ref().unwrap(), "Carthos");
1313
1314        let lux = groups
1315            .iter()
1316            .find(|g| g.agent_name == "design-fidelity-reviewer")
1317            .unwrap();
1318        assert_eq!(lux.persona.as_ref().unwrap(), "Lux");
1319    }
1320
1321    #[test]
1322    fn test_extract_review_output_with_persona_agent_name() {
1323        // Verify JSON output still parses when agent name includes persona
1324        let json = r#"{"agent":"Vigil-security-sentinel","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 security issue","pass":false}"#;
1325        let output =
1326            extract_review_output(json, "Vigil-security-sentinel", FindingCategory::Security);
1327        assert_eq!(output.agent, "Vigil-security-sentinel");
1328        assert!(!output.pass);
1329        assert_eq!(output.findings.len(), 1);
1330    }
1331
1332    // =========================================================================
1333    // ADF Remediation Tests (Gitea #117)
1334    // =========================================================================
1335
1336    #[test]
1337    fn test_compound_config_cli_tool_override() {
1338        let config = CompoundReviewConfig {
1339            schedule: "0 2 * * *".to_string(),
1340            max_duration_secs: 1800,
1341            repo_path: PathBuf::from("/tmp"),
1342            create_prs: false,
1343            worktree_root: PathBuf::from("/tmp/worktrees"),
1344            base_branch: "main".to_string(),
1345            max_concurrent_agents: 3,
1346            cli_tool: Some("/home/alex/.bun/bin/opencode".to_string()),
1347            provider: Some("opencode-go".to_string()),
1348            model: Some("glm-5".to_string()),
1349            ..Default::default()
1350        };
1351        let swarm = SwarmConfig::from_compound_config(&config);
1352        for group in &swarm.groups {
1353            assert_eq!(group.cli_tool, "/home/alex/.bun/bin/opencode");
1354            assert_eq!(group.model, Some("opencode-go/glm-5".to_string()));
1355        }
1356    }
1357
1358    #[test]
1359    fn test_compound_config_no_override() {
1360        let config = CompoundReviewConfig {
1361            schedule: "0 2 * * *".to_string(),
1362            max_duration_secs: 1800,
1363            repo_path: PathBuf::from("/tmp"),
1364            create_prs: false,
1365            worktree_root: PathBuf::from("/tmp/worktrees"),
1366            base_branch: "main".to_string(),
1367            max_concurrent_agents: 3,
1368            cli_tool: None,
1369            provider: None,
1370            model: None,
1371            ..Default::default()
1372        };
1373        let swarm = SwarmConfig::from_compound_config(&config);
1374        // Should use default groups unchanged
1375        assert_eq!(swarm.groups[0].cli_tool, "opencode");
1376        assert!(swarm.groups[0].model.is_none());
1377    }
1378
1379    // ==================== Opencode Protocol Unwrap Tests ====================
1380
1381    #[test]
1382    fn test_unwrap_opencode_protocol_formats_tool_use() {
1383        // Reproduce issue #303: tool_use messages with file content containing
1384        // "critical" were kept as raw JSON, causing the heuristic scanner to
1385        // create bogus CRITICAL findings from protocol payloads.
1386        // Now tool_use is formatted as a brief summary instead.
1387        let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Starting review..."}}
1388{"type":"tool_use","timestamp":1775340045267,"sessionID":"ses_abc","part":{"id":"prt_123","tool":"read","state":{"status":"completed","input":{"filePath":"/tmp/test.rs"},"output":"fn critical_path() { }"}}}
1389{"type":"text","part":{"type":"text","text":"Review complete. No issues found."}}"#;
1390
1391        let unwrapped = unwrap_opencode_protocol(protocol_output);
1392        // Raw payload content must not leak through
1393        assert!(
1394            !unwrapped.contains("critical_path"),
1395            "tool_use payload content should not leak through"
1396        );
1397        // Tool call should be formatted as a brief summary
1398        assert!(
1399            unwrapped.contains("[tool_use: read /tmp/test.rs completed]"),
1400            "tool_use should be formatted as summary, got: {}",
1401            unwrapped
1402        );
1403        assert!(unwrapped.contains("Starting review..."));
1404        assert!(unwrapped.contains("Review complete."));
1405    }
1406
1407    #[test]
1408    fn test_extract_review_output_no_false_critical_from_tool_use() {
1409        // End-to-end: opencode output with tool_use containing "critical"
1410        // should NOT produce synthetic CRITICAL findings.
1411        let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Reviewing code..."}}
1412{"type":"tool_use","part":{"tool":"read","state":{"output":"FindingSeverity::Critical is used here"}}}
1413{"type":"text","part":{"type":"text","text":"All looks good, no issues."}}"#;
1414
1415        let output =
1416            extract_review_output(protocol_output, "test-agent", FindingCategory::Security);
1417        // Should NOT have any findings -- the "Critical" was inside a tool_use payload
1418        assert_eq!(
1419            output.findings.len(),
1420            0,
1421            "tool_use payloads must not generate synthetic findings"
1422        );
1423    }
1424
1425    #[test]
1426    fn test_compound_config_timeout_uses_max_duration() {
1427        let config = CompoundReviewConfig {
1428            schedule: "0 2 * * *".to_string(),
1429            max_duration_secs: 900,
1430            repo_path: PathBuf::from("/tmp"),
1431            create_prs: false,
1432            worktree_root: PathBuf::from("/tmp/worktrees"),
1433            base_branch: "main".to_string(),
1434            max_concurrent_agents: 3,
1435            cli_tool: None,
1436            provider: None,
1437            model: None,
1438            ..Default::default()
1439        };
1440        let swarm = SwarmConfig::from_compound_config(&config);
1441        assert_eq!(swarm.timeout, Duration::from_secs(900));
1442    }
1443}