Skip to main content

omni_dev/claude/
client.rs

1//! Claude client for commit message improvement.
2
3use anyhow::{Context, Result};
4use tracing::{debug, info, warn};
5
6use crate::claude::token_budget::TokenBudget;
7use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
8use crate::claude::{
9    ai::{AiClient, RequestOptions, ResponseFormat},
10    error::ClaudeError,
11    prompts, response_schema,
12};
13use crate::data::{
14    amendments::{Amendment, AmendmentFile},
15    context::CommitContext,
16    RepositoryView, RepositoryViewForAI,
17};
18
19/// Returned when the full diff does not fit the token budget.
20///
21/// Carries the data needed for split dispatch so the caller can size
22/// diff chunks appropriately.
23struct BudgetExceeded {
24    /// Available input tokens for this model (context window minus output reserve).
25    available_input_tokens: usize,
26}
27
28/// Maximum retries for amendment parse/request failures (matches check retry count).
29const AMENDMENT_PARSE_MAX_RETRIES: u32 = 2;
30
31/// Claude client for commit message improvement.
32pub struct ClaudeClient {
33    /// AI client implementation.
34    ai_client: Box<dyn AiClient>,
35}
36
37impl ClaudeClient {
38    /// Creates a new Claude client with the provided AI client implementation.
39    pub fn new(ai_client: Box<dyn AiClient>) -> Self {
40        Self { ai_client }
41    }
42
43    /// Returns metadata about the AI client.
44    pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
45        self.ai_client.get_metadata()
46    }
47
48    /// Adjusts a structured-call system prompt for the active backend's
49    /// response format.
50    ///
51    /// Backends advertising
52    /// [`AiClientCapabilities::supports_response_schema`](crate::claude::ai::AiClientCapabilities::supports_response_schema)
53    /// receive the [`prompts::JSON_SCHEMA_RESPONSE_OVERRIDE`] suffix, which
54    /// instructs the model to emit a bare JSON object matching the schema
55    /// supplied via [`RequestOptions`]. Other backends receive the prompt
56    /// unchanged. Should be called once at the top of each entry point so
57    /// the suffix is included in subsequent token-budget calculations.
58    fn adjusted_system_prompt(&self, system_prompt: String) -> String {
59        let format = ResponseFormat::from_capabilities(&self.ai_client.capabilities());
60        prompts::apply_response_format_to_system_prompt(system_prompt, format)
61    }
62
63    /// Returns the cached schema when the active backend can enforce
64    /// response schemas, or `None` when it cannot.
65    ///
66    /// Used to gate per-call schema attachment so call sites stay
67    /// readable: build the schema unconditionally, gate attachment on
68    /// capabilities.
69    fn schema_if_supported<'a>(
70        &self,
71        schema: &'a serde_json::Value,
72    ) -> Option<&'a serde_json::Value> {
73        if self.ai_client.capabilities().supports_response_schema {
74            Some(schema)
75        } else {
76            None
77        }
78    }
79
80    /// Dispatches a structured AI call with optional schema enforcement.
81    ///
82    /// When `schema` is `Some`, sends via
83    /// [`AiClient::send_request_with_options`] so the backend can enforce
84    /// the schema (e.g. `claude -p --json-schema <file>`); otherwise
85    /// falls back to plain [`AiClient::send_request`]. Backends without
86    /// schema support are expected to report
87    /// [`AiClientCapabilities::supports_response_schema`](crate::claude::ai::AiClientCapabilities::supports_response_schema)
88    /// `= false`, in which case [`schema_if_supported`](Self::schema_if_supported)
89    /// at the call site returns `None` and we take the second branch.
90    async fn send_with_optional_schema(
91        &self,
92        system_prompt: &str,
93        user_prompt: &str,
94        schema: Option<&serde_json::Value>,
95    ) -> Result<String> {
96        match schema {
97            Some(s) => {
98                let opts = RequestOptions::default().with_response_schema(s.clone());
99                self.ai_client
100                    .send_request_with_options(system_prompt, user_prompt, opts)
101                    .await
102            }
103            None => {
104                self.ai_client
105                    .send_request(system_prompt, user_prompt)
106                    .await
107            }
108        }
109    }
110
111    /// Validates that the prompt fits within the model's token budget.
112    ///
113    /// Estimates token counts and logs utilization before each AI request.
114    /// Returns an error if the prompt exceeds available input tokens.
115    fn validate_prompt_budget(&self, system_prompt: &str, user_prompt: &str) -> Result<()> {
116        let metadata = self.ai_client.get_metadata();
117        let budget = TokenBudget::from_metadata(&metadata);
118        let estimate = budget.validate_prompt(system_prompt, user_prompt)?;
119
120        debug!(
121            model = %metadata.model,
122            estimated_tokens = estimate.estimated_tokens,
123            available_tokens = estimate.available_tokens,
124            utilization_pct = format!("{:.1}%", estimate.utilization_pct),
125            "Token budget check passed"
126        );
127
128        Ok(())
129    }
130
131    /// Builds a user prompt and validates it against the model's token budget.
132    ///
133    /// Serializes the repository view to YAML, constructs the user prompt, and
134    /// checks that it fits within the available input tokens. Returns an error
135    /// if the prompt exceeds the budget.
136    fn build_prompt_fitting_budget(
137        &self,
138        ai_view: &RepositoryViewForAI,
139        system_prompt: &str,
140        build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
141    ) -> Result<String> {
142        let metadata = self.ai_client.get_metadata();
143        let budget = TokenBudget::from_metadata(&metadata);
144
145        let yaml =
146            crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
147        let user_prompt = build_user_prompt(&yaml);
148
149        let estimate = budget.validate_prompt(system_prompt, &user_prompt)?;
150        debug!(
151            model = %metadata.model,
152            estimated_tokens = estimate.estimated_tokens,
153            available_tokens = estimate.available_tokens,
154            utilization_pct = format!("{:.1}%", estimate.utilization_pct),
155            "Token budget check passed"
156        );
157
158        Ok(user_prompt)
159    }
160
161    /// Tests whether the full diff fits the token budget.
162    ///
163    /// Returns `Ok(Ok(user_prompt))` when the full diff fits,
164    /// `Ok(Err(BudgetExceeded))` when it does not, or a top-level error
165    /// on serialization failure.
166    fn try_full_diff_budget(
167        &self,
168        ai_view: &RepositoryViewForAI,
169        system_prompt: &str,
170        build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
171    ) -> Result<std::result::Result<String, BudgetExceeded>> {
172        let metadata = self.ai_client.get_metadata();
173        let budget = TokenBudget::from_metadata(&metadata);
174
175        let yaml =
176            crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
177        let user_prompt = build_user_prompt(&yaml);
178
179        if let Ok(estimate) = budget.validate_prompt(system_prompt, &user_prompt) {
180            debug!(
181                model = %metadata.model,
182                estimated_tokens = estimate.estimated_tokens,
183                available_tokens = estimate.available_tokens,
184                utilization_pct = format!("{:.1}%", estimate.utilization_pct),
185                "Token budget check passed"
186            );
187            return Ok(Ok(user_prompt));
188        }
189
190        Ok(Err(BudgetExceeded {
191            available_input_tokens: budget.available_input_tokens(),
192        }))
193    }
194
195    /// Generates an amendment for a single commit whose diff exceeds the
196    /// token budget by splitting it into file-level chunks.
197    ///
198    /// Uses [`pack_file_diffs`](crate::claude::diff_pack::pack_file_diffs) to
199    /// create chunks, sends one AI request per chunk, then runs a merge pass
200    /// to synthesize a single [`Amendment`].
201    async fn generate_amendment_split(
202        &self,
203        commit: &crate::git::CommitInfo,
204        repo_view_for_ai: &RepositoryViewForAI,
205        system_prompt: &str,
206        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
207        available_input_tokens: usize,
208        fresh: bool,
209    ) -> Result<Amendment> {
210        use crate::claude::batch::{
211            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
212            VIEW_ENVELOPE_OVERHEAD_TOKENS,
213        };
214        use crate::claude::diff_pack::pack_file_diffs;
215        use crate::claude::token_budget;
216        use crate::git::commit::CommitInfoForAI;
217
218        // Compute effective capacity for diff packing by subtracting overhead
219        // that will be added when the full prompt is assembled. This mirrors
220        // the calculation in `batch::plan_batches`.
221        //
222        // Each chunk includes the FULL original_message and diff_summary (not
223        // just the partial diff), so we must subtract those from capacity.
224        // We also subtract user prompt template overhead for instruction text.
225        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
226        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
227            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
228        let chunk_capacity = available_input_tokens
229            .saturating_sub(system_prompt_tokens)
230            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
231            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
232            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
233            .saturating_sub(commit_text_tokens);
234
235        debug!(
236            commit = %&commit.hash[..8],
237            available_input_tokens,
238            system_prompt_tokens,
239            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
240            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
241            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
242            commit_text_tokens,
243            chunk_capacity,
244            "Split dispatch: computed chunk capacity"
245        );
246
247        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
248            .with_context(|| {
249                format!(
250                    "Failed to plan diff chunks for commit {}",
251                    &commit.hash[..8]
252                )
253            })?;
254
255        let total_chunks = plan.chunks.len();
256        debug!(
257            commit = %&commit.hash[..8],
258            chunks = total_chunks,
259            chunk_capacity,
260            "Split dispatch: processing commit in chunks"
261        );
262
263        let mut chunk_amendments = Vec::with_capacity(total_chunks);
264        for (i, chunk) in plan.chunks.iter().enumerate() {
265            let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
266                commit.clone(),
267                &chunk.file_paths,
268                &chunk.diff_overrides,
269            )
270            .with_context(|| {
271                format!(
272                    "Failed to build partial view for chunk {}/{} of commit {}",
273                    i + 1,
274                    total_chunks,
275                    &commit.hash[..8]
276                )
277            })?;
278
279            if fresh {
280                partial.base.original_message =
281                    "(Original message hidden - generate fresh message from diff)".to_string();
282            }
283
284            let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
285
286            // Log the actual diff content size for this chunk
287            let diff_content_len = partial.base.analysis.diff_content.len();
288            let diff_content_tokens =
289                token_budget::estimate_tokens_from_char_count(diff_content_len);
290            debug!(
291                commit = %&commit.hash[..8],
292                chunk_index = i,
293                diff_content_len,
294                diff_content_tokens,
295                "Split dispatch: chunk diff content size"
296            );
297
298            let user_prompt =
299                self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
300
301            info!(
302                commit = %&commit.hash[..8],
303                chunk = i + 1,
304                total_chunks,
305                user_prompt_len = user_prompt.len(),
306                "Split dispatch: sending chunk to AI"
307            );
308
309            let content = match self
310                .send_with_optional_schema(
311                    system_prompt,
312                    &user_prompt,
313                    self.schema_if_supported(response_schema::amendment_file_schema()),
314                )
315                .await
316            {
317                Ok(content) => content,
318                Err(e) => {
319                    // Log the underlying error before wrapping
320                    tracing::error!(
321                        commit = %&commit.hash[..8],
322                        chunk = i + 1,
323                        error = %e,
324                        error_debug = ?e,
325                        "Split dispatch: AI request failed"
326                    );
327                    return Err(e).with_context(|| {
328                        format!(
329                            "Chunk {}/{} failed for commit {}",
330                            i + 1,
331                            total_chunks,
332                            &commit.hash[..8]
333                        )
334                    });
335                }
336            };
337
338            info!(
339                commit = %&commit.hash[..8],
340                chunk = i + 1,
341                response_len = content.len(),
342                "Split dispatch: received chunk response"
343            );
344
345            let amendment_file = self.parse_amendment_response(&content).with_context(|| {
346                format!(
347                    "Failed to parse chunk {}/{} response for commit {}",
348                    i + 1,
349                    total_chunks,
350                    &commit.hash[..8]
351                )
352            })?;
353
354            if let Some(amendment) = amendment_file.amendments.into_iter().next() {
355                chunk_amendments.push(amendment);
356            }
357        }
358
359        self.merge_amendment_chunks(
360            &commit.hash,
361            &commit.original_message,
362            &commit.analysis.diff_summary,
363            &chunk_amendments,
364        )
365        .await
366    }
367
368    /// Runs an AI reduce pass to synthesize a single amendment from partial
369    /// chunk amendments for the same commit.
370    ///
371    /// Follows the same pattern as
372    /// [`refine_amendments_coherence`](Self::refine_amendments_coherence).
373    async fn merge_amendment_chunks(
374        &self,
375        commit_hash: &str,
376        original_message: &str,
377        diff_summary: &str,
378        chunk_amendments: &[Amendment],
379    ) -> Result<Amendment> {
380        let system_prompt =
381            self.adjusted_system_prompt(prompts::AMENDMENT_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
382        let user_prompt = prompts::generate_chunk_merge_user_prompt(
383            commit_hash,
384            original_message,
385            diff_summary,
386            chunk_amendments,
387        );
388
389        self.validate_prompt_budget(&system_prompt, &user_prompt)?;
390
391        let content = self
392            .send_with_optional_schema(
393                &system_prompt,
394                &user_prompt,
395                self.schema_if_supported(response_schema::amendment_file_schema()),
396            )
397            .await
398            .context("Merge pass failed for chunk amendments")?;
399
400        let amendment_file = self
401            .parse_amendment_response(&content)
402            .context("Failed to parse merge pass response")?;
403
404        amendment_file
405            .amendments
406            .into_iter()
407            .next()
408            .context("Merge pass returned no amendments")
409    }
410
411    /// Generates an amendment for a single commit, using split dispatch
412    /// if the full diff exceeds the token budget.
413    ///
414    /// Tries the full diff first. If it exceeds the budget and the commit
415    /// has file-level diffs, falls back to
416    /// [`generate_amendment_split`](Self::generate_amendment_split).
417    async fn generate_amendment_for_commit(
418        &self,
419        commit: &crate::git::CommitInfo,
420        repo_view_for_ai: &RepositoryViewForAI,
421        system_prompt: &str,
422        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
423        fresh: bool,
424    ) -> Result<Amendment> {
425        let mut ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
426        if fresh {
427            ai_commit.base.original_message =
428                "(Original message hidden - generate fresh message from diff)".to_string();
429        }
430        let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
431
432        match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
433            Ok(user_prompt) => {
434                let amendment_file = self
435                    .send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
436                    .await?;
437                amendment_file
438                    .amendments
439                    .into_iter()
440                    .next()
441                    .context("AI returned no amendments for commit")
442            }
443            Err(exceeded) => {
444                if commit.analysis.file_diffs.is_empty() {
445                    anyhow::bail!(
446                        "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
447                        &commit.hash[..8]
448                    );
449                }
450                self.generate_amendment_split(
451                    commit,
452                    repo_view_for_ai,
453                    system_prompt,
454                    build_user_prompt,
455                    exceeded.available_input_tokens,
456                    fresh,
457                )
458                .await
459            }
460        }
461    }
462
463    /// Checks a single commit whose diff exceeds the token budget by
464    /// splitting it into file-level chunks.
465    ///
466    /// Uses [`pack_file_diffs`](crate::claude::diff_pack::pack_file_diffs) to
467    /// create chunks, sends one check request per chunk, then merges results
468    /// deterministically (issue union + dedup). Runs an AI reduce pass only
469    /// when at least one chunk returns a suggestion.
470    async fn check_commit_split(
471        &self,
472        commit: &crate::git::CommitInfo,
473        repo_view: &RepositoryView,
474        system_prompt: &str,
475        valid_scopes: &[crate::data::context::ScopeDefinition],
476        include_suggestions: bool,
477        available_input_tokens: usize,
478    ) -> Result<crate::data::check::CheckReport> {
479        use crate::claude::batch::{
480            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
481            VIEW_ENVELOPE_OVERHEAD_TOKENS,
482        };
483        use crate::claude::diff_pack::pack_file_diffs;
484        use crate::claude::token_budget;
485        use crate::data::check::{CommitCheckResult, CommitIssue, IssueSeverity};
486        use crate::git::commit::CommitInfoForAI;
487
488        // Compute effective capacity for diff packing by subtracting overhead
489        // that will be added when the full prompt is assembled. This mirrors
490        // the calculation in `batch::plan_batches`.
491        //
492        // Each chunk includes the FULL original_message and diff_summary (not
493        // just the partial diff), so we must subtract those from capacity.
494        // We also subtract user prompt template overhead for instruction text.
495        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
496        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
497            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
498        let chunk_capacity = available_input_tokens
499            .saturating_sub(system_prompt_tokens)
500            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
501            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
502            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
503            .saturating_sub(commit_text_tokens);
504
505        debug!(
506            commit = %&commit.hash[..8],
507            available_input_tokens,
508            system_prompt_tokens,
509            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
510            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
511            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
512            commit_text_tokens,
513            chunk_capacity,
514            "Check split dispatch: computed chunk capacity"
515        );
516
517        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
518            .with_context(|| {
519                format!(
520                    "Failed to plan diff chunks for commit {}",
521                    &commit.hash[..8]
522                )
523            })?;
524
525        let total_chunks = plan.chunks.len();
526        debug!(
527            commit = %&commit.hash[..8],
528            chunks = total_chunks,
529            chunk_capacity,
530            "Check split dispatch: processing commit in chunks"
531        );
532
533        let build_user_prompt =
534            |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
535
536        let mut chunk_results = Vec::with_capacity(total_chunks);
537        for (i, chunk) in plan.chunks.iter().enumerate() {
538            let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
539                commit.clone(),
540                &chunk.file_paths,
541                &chunk.diff_overrides,
542            )
543            .with_context(|| {
544                format!(
545                    "Failed to build partial view for chunk {}/{} of commit {}",
546                    i + 1,
547                    total_chunks,
548                    &commit.hash[..8]
549                )
550            })?;
551
552            partial.run_pre_validation_checks(valid_scopes);
553
554            let partial_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
555                .context("Failed to enhance repository view with diff content")?
556                .single_commit_view_for_ai(&partial);
557
558            let user_prompt =
559                self.build_prompt_fitting_budget(&partial_view, system_prompt, &build_user_prompt)?;
560
561            let content = self
562                .send_with_optional_schema(
563                    system_prompt,
564                    &user_prompt,
565                    self.schema_if_supported(response_schema::check_response_schema()),
566                )
567                .await
568                .with_context(|| {
569                    format!(
570                        "Check chunk {}/{} failed for commit {}",
571                        i + 1,
572                        total_chunks,
573                        &commit.hash[..8]
574                    )
575                })?;
576
577            let report = self
578                .parse_check_response(&content, repo_view)
579                .with_context(|| {
580                    format!(
581                        "Failed to parse check chunk {}/{} response for commit {}",
582                        i + 1,
583                        total_chunks,
584                        &commit.hash[..8]
585                    )
586                })?;
587
588            if let Some(result) = report.commits.into_iter().next() {
589                chunk_results.push(result);
590            }
591        }
592
593        // Deterministic merge: union issues, dedup by (rule, severity, section)
594        let mut seen = std::collections::HashSet::new();
595        let mut merged_issues: Vec<CommitIssue> = Vec::new();
596        for result in &chunk_results {
597            for issue in &result.issues {
598                let key: (String, IssueSeverity, String) =
599                    (issue.rule.clone(), issue.severity, issue.section.clone());
600                if seen.insert(key) {
601                    merged_issues.push(issue.clone());
602                }
603            }
604        }
605
606        let passes = chunk_results.iter().all(|r| r.passes);
607
608        // AI reduce pass for suggestion/summary only when needed
609        let has_suggestions = chunk_results.iter().any(|r| r.suggestion.is_some());
610
611        let (merged_suggestion, merged_summary) = if has_suggestions {
612            self.merge_check_chunks(
613                &commit.hash,
614                &commit.original_message,
615                &commit.analysis.diff_summary,
616                passes,
617                &chunk_results,
618                repo_view,
619            )
620            .await?
621        } else {
622            // Take first non-None summary
623            let summary = chunk_results.iter().find_map(|r| r.summary.clone());
624            (None, summary)
625        };
626
627        let original_message = commit
628            .original_message
629            .lines()
630            .next()
631            .unwrap_or("")
632            .to_string();
633
634        let merged_result = CommitCheckResult {
635            hash: commit.hash.clone(),
636            message: original_message,
637            issues: merged_issues,
638            suggestion: merged_suggestion,
639            passes,
640            summary: merged_summary,
641        };
642
643        Ok(crate::data::check::CheckReport::new(vec![merged_result]))
644    }
645
646    /// Runs an AI reduce pass to synthesize a single suggestion and summary
647    /// from partial chunk check results for the same commit.
648    ///
649    /// Only called when at least one chunk returned a suggestion.
650    async fn merge_check_chunks(
651        &self,
652        commit_hash: &str,
653        original_message: &str,
654        diff_summary: &str,
655        passes: bool,
656        chunk_results: &[crate::data::check::CommitCheckResult],
657        repo_view: &RepositoryView,
658    ) -> Result<(Option<crate::data::check::CommitSuggestion>, Option<String>)> {
659        let suggestions: Vec<&crate::data::check::CommitSuggestion> = chunk_results
660            .iter()
661            .filter_map(|r| r.suggestion.as_ref())
662            .collect();
663
664        let summaries: Vec<Option<&str>> =
665            chunk_results.iter().map(|r| r.summary.as_deref()).collect();
666
667        let system_prompt =
668            self.adjusted_system_prompt(prompts::CHECK_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
669        let user_prompt = prompts::generate_check_chunk_merge_user_prompt(
670            commit_hash,
671            original_message,
672            diff_summary,
673            passes,
674            &suggestions,
675            &summaries,
676        );
677
678        self.validate_prompt_budget(&system_prompt, &user_prompt)?;
679
680        let content = self
681            .send_with_optional_schema(
682                &system_prompt,
683                &user_prompt,
684                self.schema_if_supported(response_schema::check_response_schema()),
685            )
686            .await
687            .context("Merge pass failed for check chunk suggestions")?;
688
689        let report = self
690            .parse_check_response(&content, repo_view)
691            .context("Failed to parse check merge pass response")?;
692
693        let result = report.commits.into_iter().next();
694        Ok(match result {
695            Some(r) => (r.suggestion, r.summary),
696            None => (None, None),
697        })
698    }
699
700    /// Sends a raw prompt to the AI client and returns the text response.
701    pub async fn send_message(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
702        self.validate_prompt_budget(system_prompt, user_prompt)?;
703        self.ai_client
704            .send_request(system_prompt, user_prompt)
705            .await
706    }
707
708    /// Creates a new Claude client with API key from environment variables.
709    pub fn from_env(model: String) -> Result<Self> {
710        // Try to get API key from environment variables
711        let api_key = std::env::var("CLAUDE_API_KEY")
712            .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
713            .map_err(|_| ClaudeError::ApiKeyNotFound)?;
714
715        let ai_client = ClaudeAiClient::new(model, api_key, None)?;
716        Ok(Self::new(Box::new(ai_client)))
717    }
718
719    /// Generates commit message amendments from repository view.
720    pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
721        self.generate_amendments_with_options(repo_view, false)
722            .await
723    }
724
725    /// Generates commit message amendments from repository view with options.
726    ///
727    /// If `fresh` is true, ignores existing commit messages and generates new ones
728    /// based solely on the diff content.
729    ///
730    /// For single-commit views whose full diff exceeds the token budget,
731    /// splits the diff into file-level chunks and dispatches multiple AI
732    /// requests, then merges results. Multi-commit views fall back to
733    /// progressive diff reduction (the caller retries individually on
734    /// failure).
735    pub async fn generate_amendments_with_options(
736        &self,
737        repo_view: &RepositoryView,
738        fresh: bool,
739    ) -> Result<AmendmentFile> {
740        // Convert to AI-enhanced view with diff content
741        let ai_repo_view =
742            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
743                .context("Failed to enhance repository view with diff content")?;
744
745        let system_prompt = self.adjusted_system_prompt(prompts::SYSTEM_PROMPT.to_string());
746        let build_user_prompt = |yaml: &str| prompts::generate_user_prompt(yaml);
747
748        // Try full view first; fall back to per-commit split dispatch
749        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
750            Ok(user_prompt) => {
751                self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
752                    .await
753            }
754            Err(_exceeded) => {
755                let mut amendments = Vec::new();
756                for commit in &repo_view.commits {
757                    let amendment = self
758                        .generate_amendment_for_commit(
759                            commit,
760                            &ai_repo_view,
761                            &system_prompt,
762                            &build_user_prompt,
763                            fresh,
764                        )
765                        .await?;
766                    amendments.push(amendment);
767                }
768                Ok(AmendmentFile { amendments })
769            }
770        }
771    }
772
773    /// Generates contextual commit message amendments with enhanced intelligence.
774    pub async fn generate_contextual_amendments(
775        &self,
776        repo_view: &RepositoryView,
777        context: &CommitContext,
778    ) -> Result<AmendmentFile> {
779        self.generate_contextual_amendments_with_options(repo_view, context, false)
780            .await
781    }
782
783    /// Generates contextual commit message amendments with options.
784    ///
785    /// If `fresh` is true, ignores existing commit messages and generates new ones
786    /// based solely on the diff content.
787    ///
788    /// For single-commit views whose full diff exceeds the token budget,
789    /// splits the diff into file-level chunks and dispatches multiple AI
790    /// requests, then merges results. Multi-commit views fall back to
791    /// progressive diff reduction.
792    pub async fn generate_contextual_amendments_with_options(
793        &self,
794        repo_view: &RepositoryView,
795        context: &CommitContext,
796        fresh: bool,
797    ) -> Result<AmendmentFile> {
798        // Convert to AI-enhanced view with diff content
799        let ai_repo_view =
800            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
801                .context("Failed to enhance repository view with diff content")?;
802
803        // Generate contextual prompts using intelligence
804        let prompt_style = self.ai_client.get_metadata().prompt_style();
805        let system_prompt = self.adjusted_system_prompt(
806            prompts::generate_contextual_system_prompt_for_provider(context, prompt_style),
807        );
808
809        // Debug logging to troubleshoot custom commit type issue
810        match &context.project.commit_guidelines {
811            Some(guidelines) => {
812                debug!(length = guidelines.len(), "Project commit guidelines found");
813                debug!(guidelines = %guidelines, "Commit guidelines content");
814            }
815            None => {
816                debug!("No project commit guidelines found");
817            }
818        }
819
820        let build_user_prompt =
821            |yaml: &str| prompts::generate_contextual_user_prompt(yaml, context);
822
823        // Try full view first; fall back to per-commit split dispatch
824        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
825            Ok(user_prompt) => {
826                self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
827                    .await
828            }
829            Err(_exceeded) => {
830                let mut amendments = Vec::new();
831                for commit in &repo_view.commits {
832                    let amendment = self
833                        .generate_amendment_for_commit(
834                            commit,
835                            &ai_repo_view,
836                            &system_prompt,
837                            &build_user_prompt,
838                            fresh,
839                        )
840                        .await?;
841                    amendments.push(amendment);
842                }
843                Ok(AmendmentFile { amendments })
844            }
845        }
846    }
847
848    /// Parses Claude's YAML response into an AmendmentFile.
849    fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
850        // Extract YAML from potential markdown wrapper
851        let yaml_content = self.extract_yaml_from_response(content);
852
853        // Try to parse YAML using our hybrid YAML parser
854        let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
855            debug!(
856                error = %e,
857                content_length = content.len(),
858                yaml_length = yaml_content.len(),
859                "YAML parsing failed"
860            );
861            debug!(content = %content, "Raw Claude response");
862            debug!(yaml = %yaml_content, "Extracted YAML content");
863
864            // Try to provide more helpful error messages for common issues
865            if yaml_content.lines().any(|line| line.contains('\t')) {
866                ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
867            } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
868                ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
869            } else {
870                ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {e}"))
871            }
872        })?;
873
874        // Validate the parsed amendments
875        amendment_file
876            .validate()
877            .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {e}")))?;
878
879        Ok(amendment_file)
880    }
881
882    /// Sends a prompt to the AI and parses the response as an [`AmendmentFile`],
883    /// retrying on parse or request failures.
884    ///
885    /// Mirrors the retry pattern in [`check_commits_with_retry`](Self::check_commits_with_retry):
886    /// up to [`AMENDMENT_PARSE_MAX_RETRIES`] additional attempts after the first
887    /// failure. Logs a warning via `eprintln!` and a `debug!` trace on each retry.
888    /// Returns the last error if all attempts are exhausted.
889    async fn send_and_parse_amendment_with_retry(
890        &self,
891        system_prompt: &str,
892        user_prompt: &str,
893    ) -> Result<AmendmentFile> {
894        let mut last_error = None;
895        for attempt in 0..=AMENDMENT_PARSE_MAX_RETRIES {
896            match self
897                .send_with_optional_schema(
898                    system_prompt,
899                    user_prompt,
900                    self.schema_if_supported(response_schema::amendment_file_schema()),
901                )
902                .await
903            {
904                Ok(content) => match self.parse_amendment_response(&content) {
905                    Ok(amendment_file) => return Ok(amendment_file),
906                    Err(e) => {
907                        if attempt < AMENDMENT_PARSE_MAX_RETRIES {
908                            eprintln!(
909                                "warning: failed to parse amendment response (attempt {}), retrying...",
910                                attempt + 1
911                            );
912                            debug!(error = %e, attempt = attempt + 1, "Amendment response parse failed, retrying");
913                        }
914                        last_error = Some(e);
915                    }
916                },
917                Err(e) => {
918                    if attempt < AMENDMENT_PARSE_MAX_RETRIES {
919                        eprintln!(
920                            "warning: AI request failed (attempt {}), retrying...",
921                            attempt + 1
922                        );
923                        debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
924                    }
925                    last_error = Some(e);
926                }
927            }
928        }
929        Err(last_error
930            .unwrap_or_else(|| anyhow::anyhow!("Amendment generation failed after retries")))
931    }
932
933    /// Parses an AI response as PR content YAML.
934    fn parse_pr_response(&self, content: &str) -> Result<crate::cli::git::PrContent> {
935        let yaml_content = content.trim();
936        crate::data::from_yaml(yaml_content)
937            .context("Failed to parse AI response as YAML. AI may have returned malformed output.")
938    }
939
940    /// Generates PR content for a single commit whose diff exceeds the token
941    /// budget by splitting it into file-level chunks.
942    ///
943    /// Analogous to [`generate_amendment_split`](Self::generate_amendment_split)
944    /// but produces [`PrContent`](crate::cli::git::PrContent) instead of an
945    /// amendment.
946    async fn generate_pr_content_split(
947        &self,
948        commit: &crate::git::CommitInfo,
949        repo_view_for_ai: &RepositoryViewForAI,
950        system_prompt: &str,
951        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
952        available_input_tokens: usize,
953        pr_template: &str,
954    ) -> Result<crate::cli::git::PrContent> {
955        use crate::claude::batch::{
956            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
957            VIEW_ENVELOPE_OVERHEAD_TOKENS,
958        };
959        use crate::claude::diff_pack::pack_file_diffs;
960        use crate::claude::token_budget;
961        use crate::git::commit::CommitInfoForAI;
962
963        // Compute effective capacity for diff packing by subtracting overhead
964        // that will be added when the full prompt is assembled. This mirrors
965        // the calculation in `batch::plan_batches`.
966        //
967        // Each chunk includes the FULL original_message and diff_summary (not
968        // just the partial diff), so we must subtract those from capacity.
969        // We also subtract user prompt template overhead for instruction text.
970        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
971        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
972            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
973        let chunk_capacity = available_input_tokens
974            .saturating_sub(system_prompt_tokens)
975            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
976            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
977            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
978            .saturating_sub(commit_text_tokens);
979
980        debug!(
981            commit = %&commit.hash[..8],
982            available_input_tokens,
983            system_prompt_tokens,
984            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
985            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
986            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
987            commit_text_tokens,
988            chunk_capacity,
989            "PR split dispatch: computed chunk capacity"
990        );
991
992        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
993            .with_context(|| {
994                format!(
995                    "Failed to plan diff chunks for commit {}",
996                    &commit.hash[..8]
997                )
998            })?;
999
1000        let total_chunks = plan.chunks.len();
1001        debug!(
1002            commit = %&commit.hash[..8],
1003            chunks = total_chunks,
1004            chunk_capacity,
1005            "PR split dispatch: processing commit in chunks"
1006        );
1007
1008        let mut chunk_contents = Vec::with_capacity(total_chunks);
1009        for (i, chunk) in plan.chunks.iter().enumerate() {
1010            let partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
1011                commit.clone(),
1012                &chunk.file_paths,
1013                &chunk.diff_overrides,
1014            )
1015            .with_context(|| {
1016                format!(
1017                    "Failed to build partial view for chunk {}/{} of commit {}",
1018                    i + 1,
1019                    total_chunks,
1020                    &commit.hash[..8]
1021                )
1022            })?;
1023
1024            let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
1025
1026            let user_prompt =
1027                self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
1028
1029            let content = self
1030                .send_with_optional_schema(
1031                    system_prompt,
1032                    &user_prompt,
1033                    self.schema_if_supported(response_schema::pr_content_schema()),
1034                )
1035                .await
1036                .with_context(|| {
1037                    format!(
1038                        "PR chunk {}/{} failed for commit {}",
1039                        i + 1,
1040                        total_chunks,
1041                        &commit.hash[..8]
1042                    )
1043                })?;
1044
1045            let pr_content = self.parse_pr_response(&content).with_context(|| {
1046                format!(
1047                    "Failed to parse PR chunk {}/{} response for commit {}",
1048                    i + 1,
1049                    total_chunks,
1050                    &commit.hash[..8]
1051                )
1052            })?;
1053
1054            chunk_contents.push(pr_content);
1055        }
1056
1057        self.merge_pr_content_chunks(&chunk_contents, pr_template)
1058            .await
1059    }
1060
1061    /// Runs an AI reduce pass to synthesize a single PR content from partial
1062    /// per-commit or per-chunk PR contents.
1063    async fn merge_pr_content_chunks(
1064        &self,
1065        partial_contents: &[crate::cli::git::PrContent],
1066        pr_template: &str,
1067    ) -> Result<crate::cli::git::PrContent> {
1068        let system_prompt =
1069            self.adjusted_system_prompt(prompts::PR_CONTENT_MERGE_SYSTEM_PROMPT.to_string());
1070        let user_prompt =
1071            prompts::generate_pr_content_merge_user_prompt(partial_contents, pr_template);
1072
1073        self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1074
1075        let content = self
1076            .send_with_optional_schema(
1077                &system_prompt,
1078                &user_prompt,
1079                self.schema_if_supported(response_schema::pr_content_schema()),
1080            )
1081            .await
1082            .context("Merge pass failed for PR content chunks")?;
1083
1084        self.parse_pr_response(&content)
1085            .context("Failed to parse PR content merge pass response")
1086    }
1087
1088    /// Generates PR content for a single commit, using split dispatch if needed.
1089    async fn generate_pr_content_for_commit(
1090        &self,
1091        commit: &crate::git::CommitInfo,
1092        repo_view_for_ai: &RepositoryViewForAI,
1093        system_prompt: &str,
1094        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1095        pr_template: &str,
1096    ) -> Result<crate::cli::git::PrContent> {
1097        let ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
1098        let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
1099
1100        match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1101            Ok(user_prompt) => {
1102                let content = self
1103                    .send_with_optional_schema(
1104                        system_prompt,
1105                        &user_prompt,
1106                        self.schema_if_supported(response_schema::pr_content_schema()),
1107                    )
1108                    .await?;
1109                self.parse_pr_response(&content)
1110            }
1111            Err(exceeded) => {
1112                if commit.analysis.file_diffs.is_empty() {
1113                    anyhow::bail!(
1114                        "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1115                        &commit.hash[..8]
1116                    );
1117                }
1118                self.generate_pr_content_split(
1119                    commit,
1120                    repo_view_for_ai,
1121                    system_prompt,
1122                    build_user_prompt,
1123                    exceeded.available_input_tokens,
1124                    pr_template,
1125                )
1126                .await
1127            }
1128        }
1129    }
1130
1131    /// Generates AI-powered PR content (title + description) from repository view and template.
1132    pub async fn generate_pr_content(
1133        &self,
1134        repo_view: &RepositoryView,
1135        pr_template: &str,
1136    ) -> Result<crate::cli::git::PrContent> {
1137        // Convert to AI-enhanced view with diff content
1138        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1139            .context("Failed to enhance repository view with diff content")?;
1140
1141        let system_prompt =
1142            self.adjusted_system_prompt(prompts::PR_GENERATION_SYSTEM_PROMPT.to_string());
1143
1144        let build_user_prompt =
1145            |yaml: &str| prompts::generate_pr_description_prompt(yaml, pr_template);
1146
1147        // Try full view first; fall back to per-commit split dispatch
1148        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1149            Ok(user_prompt) => {
1150                let content = self
1151                    .send_with_optional_schema(
1152                        &system_prompt,
1153                        &user_prompt,
1154                        self.schema_if_supported(response_schema::pr_content_schema()),
1155                    )
1156                    .await?;
1157                self.parse_pr_response(&content)
1158            }
1159            Err(_exceeded) => {
1160                let mut per_commit_contents = Vec::new();
1161                for commit in &repo_view.commits {
1162                    let pr = self
1163                        .generate_pr_content_for_commit(
1164                            commit,
1165                            &ai_repo_view,
1166                            &system_prompt,
1167                            &build_user_prompt,
1168                            pr_template,
1169                        )
1170                        .await?;
1171                    per_commit_contents.push(pr);
1172                }
1173                if per_commit_contents.len() == 1 {
1174                    return per_commit_contents
1175                        .into_iter()
1176                        .next()
1177                        .context("Per-commit PR contents unexpectedly empty");
1178                }
1179                self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1180                    .await
1181            }
1182        }
1183    }
1184
1185    /// Generates AI-powered PR content with project context (title + description).
1186    pub async fn generate_pr_content_with_context(
1187        &self,
1188        repo_view: &RepositoryView,
1189        pr_template: &str,
1190        context: &crate::data::context::CommitContext,
1191    ) -> Result<crate::cli::git::PrContent> {
1192        // Convert to AI-enhanced view with diff content
1193        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1194            .context("Failed to enhance repository view with diff content")?;
1195
1196        // Generate contextual prompts for PR description with provider-specific handling
1197        let prompt_style = self.ai_client.get_metadata().prompt_style();
1198        let system_prompt = self.adjusted_system_prompt(
1199            prompts::generate_pr_system_prompt_with_context_for_provider(context, prompt_style),
1200        );
1201
1202        let build_user_prompt = |yaml: &str| {
1203            prompts::generate_pr_description_prompt_with_context(yaml, pr_template, context)
1204        };
1205
1206        // Try full view first; fall back to per-commit split dispatch
1207        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1208            Ok(user_prompt) => {
1209                let content = self
1210                    .send_with_optional_schema(
1211                        &system_prompt,
1212                        &user_prompt,
1213                        self.schema_if_supported(response_schema::pr_content_schema()),
1214                    )
1215                    .await?;
1216
1217                debug!(
1218                    content_length = content.len(),
1219                    "Received AI response for PR content"
1220                );
1221
1222                let pr_content = self.parse_pr_response(&content)?;
1223
1224                debug!(
1225                    parsed_title = %pr_content.title,
1226                    parsed_description_length = pr_content.description.len(),
1227                    parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1228                    "Successfully parsed PR content from YAML"
1229                );
1230
1231                Ok(pr_content)
1232            }
1233            Err(_exceeded) => {
1234                let mut per_commit_contents = Vec::new();
1235                for commit in &repo_view.commits {
1236                    let pr = self
1237                        .generate_pr_content_for_commit(
1238                            commit,
1239                            &ai_repo_view,
1240                            &system_prompt,
1241                            &build_user_prompt,
1242                            pr_template,
1243                        )
1244                        .await?;
1245                    per_commit_contents.push(pr);
1246                }
1247                if per_commit_contents.len() == 1 {
1248                    return per_commit_contents
1249                        .into_iter()
1250                        .next()
1251                        .context("Per-commit PR contents unexpectedly empty");
1252                }
1253                self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1254                    .await
1255            }
1256        }
1257    }
1258
1259    /// Checks commit messages against guidelines and returns a report.
1260    ///
1261    /// Validates commit messages against project guidelines or defaults,
1262    /// returning a structured report with issues and suggestions.
1263    pub async fn check_commits(
1264        &self,
1265        repo_view: &RepositoryView,
1266        guidelines: Option<&str>,
1267        include_suggestions: bool,
1268    ) -> Result<crate::data::check::CheckReport> {
1269        self.check_commits_with_scopes(repo_view, guidelines, &[], include_suggestions)
1270            .await
1271    }
1272
1273    /// Checks commit messages against guidelines with valid scopes and returns a report.
1274    ///
1275    /// Validates commit messages against project guidelines or defaults,
1276    /// using the provided valid scopes for scope validation.
1277    pub async fn check_commits_with_scopes(
1278        &self,
1279        repo_view: &RepositoryView,
1280        guidelines: Option<&str>,
1281        valid_scopes: &[crate::data::context::ScopeDefinition],
1282        include_suggestions: bool,
1283    ) -> Result<crate::data::check::CheckReport> {
1284        self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
1285            .await
1286    }
1287
1288    /// Checks commit messages with retry logic for parse failures.
1289    ///
1290    /// For single-commit views whose full diff exceeds the token budget,
1291    /// splits the diff into file-level chunks and dispatches multiple AI
1292    /// requests, then merges results. Multi-commit views fall back to
1293    /// progressive diff reduction (the caller retries individually on
1294    /// failure).
1295    async fn check_commits_with_retry(
1296        &self,
1297        repo_view: &RepositoryView,
1298        guidelines: Option<&str>,
1299        valid_scopes: &[crate::data::context::ScopeDefinition],
1300        include_suggestions: bool,
1301        max_retries: u32,
1302    ) -> Result<crate::data::check::CheckReport> {
1303        // Generate system prompt with scopes
1304        let system_prompt = self.adjusted_system_prompt(
1305            prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes),
1306        );
1307
1308        let build_user_prompt =
1309            |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
1310
1311        let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1312            .context("Failed to enhance repository view with diff content")?;
1313        for commit in &mut ai_repo_view.commits {
1314            commit.run_pre_validation_checks(valid_scopes);
1315        }
1316
1317        // Try full view first; fall back to per-commit split dispatch
1318        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1319            Ok(user_prompt) => {
1320                // Full view fits: send with retry loop
1321                let mut last_error = None;
1322                for attempt in 0..=max_retries {
1323                    match self
1324                        .send_with_optional_schema(
1325                            &system_prompt,
1326                            &user_prompt,
1327                            self.schema_if_supported(response_schema::check_response_schema()),
1328                        )
1329                        .await
1330                    {
1331                        Ok(content) => match self.parse_check_response(&content, repo_view) {
1332                            Ok(report) => return Ok(report),
1333                            Err(e) => {
1334                                if attempt < max_retries {
1335                                    eprintln!(
1336                                        "warning: failed to parse AI response (attempt {}), retrying...",
1337                                        attempt + 1
1338                                    );
1339                                    debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
1340                                }
1341                                last_error = Some(e);
1342                            }
1343                        },
1344                        Err(e) => {
1345                            if attempt < max_retries {
1346                                eprintln!(
1347                                    "warning: AI request failed (attempt {}), retrying...",
1348                                    attempt + 1
1349                                );
1350                                debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
1351                            }
1352                            last_error = Some(e);
1353                        }
1354                    }
1355                }
1356                Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
1357            }
1358            Err(_exceeded) => {
1359                // Per-commit split dispatch
1360                let mut all_results = Vec::new();
1361                for commit in &repo_view.commits {
1362                    let single_view = repo_view.single_commit_view(commit);
1363                    let mut single_ai_view =
1364                        RepositoryViewForAI::from_repository_view(single_view.clone())
1365                            .context("Failed to enhance single-commit view with diff content")?;
1366                    for c in &mut single_ai_view.commits {
1367                        c.run_pre_validation_checks(valid_scopes);
1368                    }
1369
1370                    match self.try_full_diff_budget(
1371                        &single_ai_view,
1372                        &system_prompt,
1373                        &build_user_prompt,
1374                    )? {
1375                        Ok(user_prompt) => {
1376                            let content = self
1377                                .send_with_optional_schema(
1378                                    &system_prompt,
1379                                    &user_prompt,
1380                                    self.schema_if_supported(
1381                                        response_schema::check_response_schema(),
1382                                    ),
1383                                )
1384                                .await?;
1385                            let report = self.parse_check_response(&content, &single_view)?;
1386                            all_results.extend(report.commits);
1387                        }
1388                        Err(exceeded) => {
1389                            if commit.analysis.file_diffs.is_empty() {
1390                                anyhow::bail!(
1391                                    "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1392                                    &commit.hash[..8]
1393                                );
1394                            }
1395                            let report = self
1396                                .check_commit_split(
1397                                    commit,
1398                                    &single_view,
1399                                    &system_prompt,
1400                                    valid_scopes,
1401                                    include_suggestions,
1402                                    exceeded.available_input_tokens,
1403                                )
1404                                .await?;
1405                            all_results.extend(report.commits);
1406                        }
1407                    }
1408                }
1409                Ok(crate::data::check::CheckReport::new(all_results))
1410            }
1411        }
1412    }
1413
1414    /// Parses the check response from AI.
1415    fn parse_check_response(
1416        &self,
1417        content: &str,
1418        repo_view: &RepositoryView,
1419    ) -> Result<crate::data::check::CheckReport> {
1420        use crate::data::check::{
1421            AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
1422        };
1423
1424        // Extract YAML from potential markdown wrapper
1425        let yaml_content = self.extract_yaml_from_check_response(content);
1426
1427        // Parse YAML response
1428        let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
1429            debug!(
1430                error = %e,
1431                content_length = content.len(),
1432                yaml_length = yaml_content.len(),
1433                "Check YAML parsing failed"
1434            );
1435            debug!(content = %content, "Raw AI response");
1436            debug!(yaml = %yaml_content, "Extracted YAML content");
1437            ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {e}"))
1438        })?;
1439
1440        // Create a map of commit hashes to original messages for lookup
1441        let commit_messages: std::collections::HashMap<&str, &str> = repo_view
1442            .commits
1443            .iter()
1444            .map(|c| (c.hash.as_str(), c.original_message.as_str()))
1445            .collect();
1446
1447        // Convert AI response to CheckReport
1448        let results: Vec<CheckResultType> = ai_response
1449            .checks
1450            .into_iter()
1451            .map(|check| {
1452                let mut result: CheckResultType = check.into();
1453                // Fill in the original message from repo_view
1454                if let Some(msg) = commit_messages.get(result.hash.as_str()) {
1455                    result.message = msg.lines().next().unwrap_or("").to_string();
1456                } else {
1457                    // Try to find by prefix
1458                    for (hash, msg) in &commit_messages {
1459                        if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
1460                            result.message = msg.lines().next().unwrap_or("").to_string();
1461                            break;
1462                        }
1463                    }
1464                }
1465                result
1466            })
1467            .collect();
1468
1469        Ok(CheckReport::new(results))
1470    }
1471
1472    /// Extracts YAML content from check response, handling markdown wrappers.
1473    fn extract_yaml_from_check_response(&self, content: &str) -> String {
1474        let content = content.trim();
1475
1476        // If content already starts with "checks:", it's pure YAML - return as-is
1477        if content.starts_with("checks:") {
1478            return content.to_string();
1479        }
1480
1481        // Try to extract from ```yaml blocks first
1482        if let Some(yaml_start) = content.find("```yaml") {
1483            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1484                return yaml_content.trim().to_string();
1485            }
1486        }
1487
1488        // Try to extract from generic ``` blocks
1489        if let Some(code_start) = content.find("```") {
1490            if let Some(code_content) = content[code_start + 3..].split("```").next() {
1491                let potential_yaml = code_content.trim();
1492                // Check if it looks like YAML (starts with expected structure)
1493                if potential_yaml.starts_with("checks:") {
1494                    return potential_yaml.to_string();
1495                }
1496            }
1497        }
1498
1499        // If no markdown blocks found or extraction failed, return trimmed content
1500        content.to_string()
1501    }
1502
1503    /// Refines individually-generated amendments for cross-commit coherence.
1504    ///
1505    /// Sends commit summaries and proposed messages to the AI for a second pass
1506    /// that normalizes scopes, detects rename chains, and removes redundancy.
1507    pub async fn refine_amendments_coherence(
1508        &self,
1509        items: &[(crate::data::amendments::Amendment, String)],
1510    ) -> Result<AmendmentFile> {
1511        let system_prompt =
1512            self.adjusted_system_prompt(prompts::AMENDMENT_COHERENCE_SYSTEM_PROMPT.to_string());
1513        let user_prompt = prompts::generate_amendment_coherence_user_prompt(items);
1514
1515        self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1516
1517        let content = self
1518            .send_with_optional_schema(
1519                &system_prompt,
1520                &user_prompt,
1521                self.schema_if_supported(response_schema::amendment_file_schema()),
1522            )
1523            .await?;
1524
1525        self.parse_amendment_response(&content)
1526    }
1527
1528    /// Refines individually-generated check results for cross-commit coherence.
1529    ///
1530    /// Sends commit summaries and check outcomes to the AI for a second pass
1531    /// that ensures consistent severity, detects cross-commit issues, and
1532    /// normalizes scope validation.
1533    pub async fn refine_checks_coherence(
1534        &self,
1535        items: &[(crate::data::check::CommitCheckResult, String)],
1536        repo_view: &RepositoryView,
1537    ) -> Result<crate::data::check::CheckReport> {
1538        let system_prompt =
1539            self.adjusted_system_prompt(prompts::CHECK_COHERENCE_SYSTEM_PROMPT.to_string());
1540        let user_prompt = prompts::generate_check_coherence_user_prompt(items);
1541
1542        self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1543
1544        let content = self
1545            .send_with_optional_schema(
1546                &system_prompt,
1547                &user_prompt,
1548                self.schema_if_supported(response_schema::check_response_schema()),
1549            )
1550            .await?;
1551
1552        self.parse_check_response(&content, repo_view)
1553    }
1554
1555    /// Extracts YAML content from Claude response, handling markdown wrappers.
1556    fn extract_yaml_from_response(&self, content: &str) -> String {
1557        let content = content.trim();
1558
1559        // If content already starts with "amendments:", it's pure YAML - return as-is
1560        if content.starts_with("amendments:") {
1561            return content.to_string();
1562        }
1563
1564        // Try to extract from ```yaml blocks first
1565        if let Some(yaml_start) = content.find("```yaml") {
1566            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1567                return yaml_content.trim().to_string();
1568            }
1569        }
1570
1571        // Try to extract from generic ``` blocks
1572        if let Some(code_start) = content.find("```") {
1573            if let Some(code_content) = content[code_start + 3..].split("```").next() {
1574                let potential_yaml = code_content.trim();
1575                // Check if it looks like YAML (starts with expected structure)
1576                if potential_yaml.starts_with("amendments:") {
1577                    return potential_yaml.to_string();
1578                }
1579            }
1580        }
1581
1582        // If no markdown blocks found or extraction failed, return trimmed content
1583        content.to_string()
1584    }
1585}
1586
1587/// Validates a beta header against the model registry.
1588fn validate_beta_header(model: &str, beta_header: &Option<(String, String)>) -> Result<()> {
1589    if let Some((ref key, ref value)) = beta_header {
1590        let registry = crate::claude::model_config::get_model_registry();
1591        let supported = registry.get_beta_headers(model);
1592        if !supported
1593            .iter()
1594            .any(|bh| bh.key == *key && bh.value == *value)
1595        {
1596            let available: Vec<String> = supported
1597                .iter()
1598                .map(|bh| format!("{}:{}", bh.key, bh.value))
1599                .collect();
1600            if available.is_empty() {
1601                anyhow::bail!("Model '{model}' does not support any beta headers");
1602            }
1603            anyhow::bail!(
1604                "Beta header '{key}:{value}' is not supported for model '{model}'. Supported: {}",
1605                available.join(", ")
1606            );
1607        }
1608    }
1609    Ok(())
1610}
1611
1612/// Creates a default Claude client using environment variables and settings.
1613pub fn create_default_claude_client(
1614    model: Option<String>,
1615    beta_header: Option<(String, String)>,
1616) -> Result<ClaudeClient> {
1617    use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1618    use crate::claude::ai::openai::OpenAiAiClient;
1619    use crate::utils::settings::{get_env_var, get_env_vars};
1620
1621    // `claude -p` subprocess backend takes precedence when requested — it
1622    // reuses an existing Claude Code auth session and is the only backend
1623    // that accepts short model aliases (sonnet/opus/haiku), so it must
1624    // short-circuit before `validate_beta_header` runs below.
1625    let ai_backend = get_env_var("OMNI_DEV_AI_BACKEND").ok();
1626    let use_claude_cli = ai_backend
1627        .as_deref()
1628        .is_some_and(|v| matches!(v, "claude-cli" | "claude_cli"));
1629
1630    if use_claude_cli {
1631        if beta_header.is_some() {
1632            warn!(
1633                "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1634                 (the CLI's --betas flag has different semantics and is not forwarded)"
1635            );
1636        }
1637        let registry = crate::claude::model_config::get_model_registry();
1638        let cli_model = model
1639            .or_else(|| get_env_var("CLAUDE_MODEL").ok())
1640            .or_else(|| get_env_var("CLAUDE_CODE_MODEL").ok())
1641            .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1642            .unwrap_or_else(|| {
1643                registry
1644                    .get_default_model("claude")
1645                    .unwrap_or("claude-sonnet-4-6")
1646                    .to_string()
1647            });
1648        debug!(model = %cli_model, "Creating claude -p subprocess client");
1649        let ai_client = ClaudeCliAiClient::new(cli_model);
1650        return Ok(ClaudeClient::new(Box::new(ai_client)));
1651    }
1652
1653    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
1654    let use_openai = get_env_var("USE_OPENAI").is_ok_and(|val| val == "true");
1655
1656    let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1657
1658    // Check if we should use Bedrock
1659    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1660
1661    debug!(
1662        use_openai = use_openai,
1663        use_ollama = use_ollama,
1664        use_bedrock = use_bedrock,
1665        "Client selection flags"
1666    );
1667
1668    let registry = crate::claude::model_config::get_model_registry();
1669
1670    // Handle Ollama configuration
1671    if use_ollama {
1672        let ollama_model = model
1673            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1674            .unwrap_or_else(|| "llama2".to_string());
1675        validate_beta_header(&ollama_model, &beta_header)?;
1676        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1677        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1678        return Ok(ClaudeClient::new(Box::new(ai_client)));
1679    }
1680
1681    // Handle OpenAI configuration
1682    if use_openai {
1683        debug!("Creating OpenAI client");
1684        let openai_model = model
1685            .or_else(|| get_env_var("OPENAI_MODEL").ok())
1686            .unwrap_or_else(|| {
1687                registry
1688                    .get_default_model("openai")
1689                    .unwrap_or("gpt-5")
1690                    .to_string()
1691            });
1692        debug!(openai_model = %openai_model, "Selected OpenAI model");
1693        validate_beta_header(&openai_model, &beta_header)?;
1694
1695        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1696            debug!(error = ?e, "Failed to get OpenAI API key");
1697            ClaudeError::ApiKeyNotFound
1698        })?;
1699        debug!("OpenAI API key found");
1700
1701        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1702        debug!("OpenAI client created successfully");
1703        return Ok(ClaudeClient::new(Box::new(ai_client)));
1704    }
1705
1706    // For Claude clients, try to get model from env vars or use default
1707    let claude_model = model
1708        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1709        .unwrap_or_else(|| {
1710            registry
1711                .get_default_model("claude")
1712                .unwrap_or("claude-sonnet-4-6")
1713                .to_string()
1714        });
1715    validate_beta_header(&claude_model, &beta_header)?;
1716
1717    if use_bedrock {
1718        // Use Bedrock AI client
1719        let auth_token =
1720            get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1721
1722        let base_url =
1723            get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1724
1725        let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1726        return Ok(ClaudeClient::new(Box::new(ai_client)));
1727    }
1728
1729    // Default: use standard Claude AI client
1730    debug!("Falling back to Claude client");
1731    let api_key = get_env_vars(&[
1732        "CLAUDE_API_KEY",
1733        "ANTHROPIC_API_KEY",
1734        "ANTHROPIC_AUTH_TOKEN",
1735    ])
1736    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1737
1738    let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1739    debug!("Claude client created successfully");
1740    Ok(ClaudeClient::new(Box::new(ai_client)))
1741}
1742
1743#[cfg(test)]
1744#[allow(clippy::unwrap_used, clippy::expect_used)]
1745mod tests {
1746    use super::*;
1747    use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1748    use std::future::Future;
1749    use std::pin::Pin;
1750    use std::sync::{Arc, Mutex};
1751
1752    /// Mock AI client for testing — never makes real HTTP requests.
1753    struct MockAiClient;
1754
1755    impl AiClient for MockAiClient {
1756        fn send_request<'a>(
1757            &'a self,
1758            _system_prompt: &'a str,
1759            _user_prompt: &'a str,
1760        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1761            Box::pin(async { Ok(String::new()) })
1762        }
1763
1764        fn get_metadata(&self) -> AiClientMetadata {
1765            AiClientMetadata {
1766                provider: "Mock".to_string(),
1767                model: "mock-model".to_string(),
1768                max_context_length: 200_000,
1769                max_response_length: 8_192,
1770                active_beta: None,
1771            }
1772        }
1773    }
1774
1775    fn make_client() -> ClaudeClient {
1776        ClaudeClient::new(Box::new(MockAiClient))
1777    }
1778
1779    /// Mock AI client that records both prompts and per-call options
1780    /// (the schema attached, if any). Used to verify
1781    /// [`ClaudeClient::send_with_optional_schema`] dispatches via the
1782    /// options-aware method when a schema is provided and via the plain
1783    /// method otherwise.
1784    ///
1785    /// Returns the configured `response` string from both `send_request`
1786    /// and `send_request_with_options` so tests that need a parseable
1787    /// response (e.g. the refine_* coherence paths) can plug in canned
1788    /// YAML/JSON.
1789    struct SchemaRecordingMockAiClient {
1790        capabilities: AiClientCapabilities,
1791        response: String,
1792        recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1793        recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1794    }
1795    impl SchemaRecordingMockAiClient {
1796        fn new(supports_response_schema: bool) -> Self {
1797            Self::with_response(supports_response_schema, String::new())
1798        }
1799
1800        fn with_response(supports_response_schema: bool, response: String) -> Self {
1801            Self {
1802                capabilities: AiClientCapabilities {
1803                    supports_response_schema,
1804                },
1805                response,
1806                recorded_options: Arc::new(Mutex::new(Vec::new())),
1807                recorded_plain: Arc::new(Mutex::new(Vec::new())),
1808            }
1809        }
1810    }
1811
1812    impl AiClient for SchemaRecordingMockAiClient {
1813        fn send_request<'a>(
1814            &'a self,
1815            system_prompt: &'a str,
1816            user_prompt: &'a str,
1817        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1818            let plain = self.recorded_plain.clone();
1819            let sys = system_prompt.to_string();
1820            let usr = user_prompt.to_string();
1821            let response = self.response.clone();
1822            Box::pin(async move {
1823                plain.lock().unwrap().push((sys, usr));
1824                Ok(response)
1825            })
1826        }
1827
1828        fn capabilities(&self) -> AiClientCapabilities {
1829            self.capabilities
1830        }
1831
1832        fn send_request_with_options<'a>(
1833            &'a self,
1834            _system_prompt: &'a str,
1835            _user_prompt: &'a str,
1836            options: RequestOptions,
1837        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1838            let recorded = self.recorded_options.clone();
1839            let response = self.response.clone();
1840            Box::pin(async move {
1841                recorded.lock().unwrap().push(options);
1842                Ok(response)
1843            })
1844        }
1845
1846        fn get_metadata(&self) -> AiClientMetadata {
1847            AiClientMetadata {
1848                provider: "SchemaMock".to_string(),
1849                model: "schema-mock".to_string(),
1850                max_context_length: 200_000,
1851                max_response_length: 8_192,
1852                active_beta: None,
1853            }
1854        }
1855    }
1856
1857    // ── ClaudeClient schema-routing helpers ───────────────────────────
1858
1859    /// Backends that don't advertise schema support take the
1860    /// `send_request` branch in `send_with_optional_schema` regardless
1861    /// of whether a schema was supplied at the call site.
1862    #[tokio::test]
1863    async fn send_with_optional_schema_without_caps_uses_plain_send() {
1864        let inner = SchemaRecordingMockAiClient::new(false);
1865        let plain_log = inner.recorded_plain.clone();
1866        let opts_log = inner.recorded_options.clone();
1867        let client = ClaudeClient::new(Box::new(inner));
1868
1869        let schema = serde_json::json!({"type": "object"});
1870        client
1871            .send_with_optional_schema(
1872                "sys",
1873                "usr",
1874                client.schema_if_supported(&schema), // → None
1875            )
1876            .await
1877            .unwrap();
1878
1879        assert_eq!(plain_log.lock().unwrap().len(), 1);
1880        assert!(opts_log.lock().unwrap().is_empty());
1881    }
1882
1883    /// Backends that advertise schema support take the
1884    /// `send_request_with_options` branch and receive the schema in the
1885    /// options struct.
1886    #[tokio::test]
1887    async fn send_with_optional_schema_with_caps_uses_options_send() {
1888        let inner = SchemaRecordingMockAiClient::new(true);
1889        let plain_log = inner.recorded_plain.clone();
1890        let opts_log = inner.recorded_options.clone();
1891        let client = ClaudeClient::new(Box::new(inner));
1892
1893        let schema = serde_json::json!({"type": "object", "additionalProperties": false});
1894        client
1895            .send_with_optional_schema(
1896                "sys",
1897                "usr",
1898                client.schema_if_supported(&schema), // → Some
1899            )
1900            .await
1901            .unwrap();
1902
1903        let recorded = opts_log.lock().unwrap();
1904        assert_eq!(recorded.len(), 1);
1905        assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
1906        assert!(plain_log.lock().unwrap().is_empty());
1907    }
1908
1909    /// `adjusted_system_prompt` only appends the JSON-schema override
1910    /// suffix when the active backend supports schema enforcement.
1911    #[test]
1912    fn adjusted_system_prompt_adds_suffix_when_supported() {
1913        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1914        let result = client.adjusted_system_prompt("body".to_string());
1915        assert!(result.starts_with("body"));
1916        assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
1917    }
1918
1919    #[test]
1920    fn adjusted_system_prompt_passes_through_when_not_supported() {
1921        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1922        let result = client.adjusted_system_prompt("body".to_string());
1923        assert_eq!(result, "body");
1924    }
1925
1926    #[test]
1927    fn schema_if_supported_returns_some_when_supported() {
1928        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1929        let schema = serde_json::json!({"type": "object"});
1930        let returned = client.schema_if_supported(&schema);
1931        assert!(returned.is_some());
1932        assert!(std::ptr::eq(
1933            returned.unwrap() as *const _,
1934            &schema as *const _
1935        ));
1936    }
1937
1938    #[test]
1939    fn schema_if_supported_returns_none_when_not_supported() {
1940        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1941        let schema = serde_json::json!({"type": "object"});
1942        assert!(client.schema_if_supported(&schema).is_none());
1943    }
1944
1945    // ── refine_amendments_coherence / refine_checks_coherence ────────
1946
1947    /// Exercises the full body of `refine_amendments_coherence`:
1948    /// adjusted_system_prompt → validate_prompt_budget → schema-aware
1949    /// dispatch → parse_amendment_response. Uses a schema-supporting
1950    /// mock so the schema attachment branch is taken too.
1951    #[tokio::test]
1952    async fn refine_amendments_coherence_round_trip() {
1953        let mock = SchemaRecordingMockAiClient::with_response(
1954            true, // supports_response_schema
1955            "amendments: []".to_string(),
1956        );
1957        let recorded_opts = mock.recorded_options.clone();
1958        let client = ClaudeClient::new(Box::new(mock));
1959
1960        let amendment = crate::data::amendments::Amendment {
1961            commit: "abc123".to_string(),
1962            message: "feat: do thing".to_string(),
1963            summary: Some("did the thing".to_string()),
1964        };
1965        let items = vec![(amendment, "summary text".to_string())];
1966
1967        let result = client
1968            .refine_amendments_coherence(&items)
1969            .await
1970            .expect("coherence refinement should succeed");
1971        assert!(result.amendments.is_empty());
1972
1973        // Verify the schema-aware dispatch path was taken and that the
1974        // attached schema is the AmendmentFile schema.
1975        let recorded = recorded_opts.lock().unwrap();
1976        assert_eq!(recorded.len(), 1);
1977        let attached = recorded[0]
1978            .response_schema
1979            .as_ref()
1980            .expect("schema must be attached when capability is true");
1981        assert_eq!(
1982            attached,
1983            response_schema::amendment_file_schema(),
1984            "refine_amendments_coherence should attach the AmendmentFile schema"
1985        );
1986    }
1987
1988    /// Same coverage shape as the amendment variant, but for the check
1989    /// coherence path. Uses `parse_check_response` which needs a
1990    /// repository view to map commit hashes back to messages — we
1991    /// supply an empty view.
1992    #[tokio::test]
1993    async fn refine_checks_coherence_round_trip() {
1994        let mock = SchemaRecordingMockAiClient::with_response(
1995            true, // supports_response_schema
1996            "checks: []".to_string(),
1997        );
1998        let recorded_opts = mock.recorded_options.clone();
1999        let client = ClaudeClient::new(Box::new(mock));
2000
2001        let check = crate::data::check::CommitCheckResult {
2002            hash: "abc123".to_string(),
2003            message: "feat: do thing".to_string(),
2004            issues: Vec::new(),
2005            suggestion: None,
2006            passes: true,
2007            summary: Some("summary".to_string()),
2008        };
2009        let items = vec![(check, "summary text".to_string())];
2010        let dir = tempfile::TempDir::new().unwrap();
2011        let repo_view = make_test_repo_view(&dir);
2012
2013        let result = client
2014            .refine_checks_coherence(&items, &repo_view)
2015            .await
2016            .expect("coherence refinement should succeed");
2017        assert_eq!(result.summary.total_commits, 0);
2018
2019        let recorded = recorded_opts.lock().unwrap();
2020        assert_eq!(recorded.len(), 1);
2021        let attached = recorded[0]
2022            .response_schema
2023            .as_ref()
2024            .expect("schema must be attached when capability is true");
2025        assert_eq!(
2026            attached,
2027            response_schema::check_response_schema(),
2028            "refine_checks_coherence should attach the AiCheckResponse schema"
2029        );
2030    }
2031
2032    /// Verifies the no-schema branch of refine_amendments_coherence —
2033    /// when the backend doesn't advertise schema support, dispatch
2034    /// falls through to plain `send_request` and no schema is attached.
2035    #[tokio::test]
2036    async fn refine_amendments_coherence_without_schema_capability() {
2037        let mock = SchemaRecordingMockAiClient::with_response(
2038            false, // supports_response_schema
2039            "amendments: []".to_string(),
2040        );
2041        let recorded_plain = mock.recorded_plain.clone();
2042        let recorded_opts = mock.recorded_options.clone();
2043        let client = ClaudeClient::new(Box::new(mock));
2044
2045        let amendment = crate::data::amendments::Amendment {
2046            commit: "abc123".to_string(),
2047            message: "feat: do thing".to_string(),
2048            summary: None,
2049        };
2050        let items = vec![(amendment, "summary".to_string())];
2051
2052        client
2053            .refine_amendments_coherence(&items)
2054            .await
2055            .expect("coherence refinement should succeed without schema support");
2056
2057        assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2058        assert!(
2059            recorded_opts.lock().unwrap().is_empty(),
2060            "no-schema backend must not be reached via the options path"
2061        );
2062    }
2063
2064    // ── extract_yaml_from_response ─────────────────────────────────
2065
2066    #[test]
2067    fn extract_yaml_pure_amendments() {
2068        let client = make_client();
2069        let content = "amendments:\n  - commit: abc123\n    message: test";
2070        let result = client.extract_yaml_from_response(content);
2071        assert!(result.starts_with("amendments:"));
2072    }
2073
2074    #[test]
2075    fn extract_yaml_with_markdown_yaml_block() {
2076        let client = make_client();
2077        let content = "Here is the result:\n```yaml\namendments:\n  - commit: abc\n```\n";
2078        let result = client.extract_yaml_from_response(content);
2079        assert!(result.starts_with("amendments:"));
2080    }
2081
2082    #[test]
2083    fn extract_yaml_with_generic_code_block() {
2084        let client = make_client();
2085        let content = "```\namendments:\n  - commit: abc\n```";
2086        let result = client.extract_yaml_from_response(content);
2087        assert!(result.starts_with("amendments:"));
2088    }
2089
2090    #[test]
2091    fn extract_yaml_with_whitespace() {
2092        let client = make_client();
2093        let content = "  \n  amendments:\n  - commit: abc\n  ";
2094        let result = client.extract_yaml_from_response(content);
2095        assert!(result.starts_with("amendments:"));
2096    }
2097
2098    #[test]
2099    fn extract_yaml_fallback_returns_trimmed() {
2100        let client = make_client();
2101        let content = "  some random text  ";
2102        let result = client.extract_yaml_from_response(content);
2103        assert_eq!(result, "some random text");
2104    }
2105
2106    // ── extract_yaml_from_check_response ───────────────────────────
2107
2108    #[test]
2109    fn extract_check_yaml_pure() {
2110        let client = make_client();
2111        let content = "checks:\n  - commit: abc123";
2112        let result = client.extract_yaml_from_check_response(content);
2113        assert!(result.starts_with("checks:"));
2114    }
2115
2116    #[test]
2117    fn extract_check_yaml_markdown_block() {
2118        let client = make_client();
2119        let content = "```yaml\nchecks:\n  - commit: abc\n```";
2120        let result = client.extract_yaml_from_check_response(content);
2121        assert!(result.starts_with("checks:"));
2122    }
2123
2124    #[test]
2125    fn extract_check_yaml_generic_block() {
2126        let client = make_client();
2127        let content = "```\nchecks:\n  - commit: abc\n```";
2128        let result = client.extract_yaml_from_check_response(content);
2129        assert!(result.starts_with("checks:"));
2130    }
2131
2132    #[test]
2133    fn extract_check_yaml_fallback() {
2134        let client = make_client();
2135        let content = "  unexpected content  ";
2136        let result = client.extract_yaml_from_check_response(content);
2137        assert_eq!(result, "unexpected content");
2138    }
2139
2140    // ── parse_amendment_response ────────────────────────────────────
2141
2142    #[test]
2143    fn parse_amendment_response_valid() {
2144        let client = make_client();
2145        let yaml = format!(
2146            "amendments:\n  - commit: \"{}\"\n    message: \"test message\"",
2147            "a".repeat(40)
2148        );
2149        let result = client.parse_amendment_response(&yaml);
2150        assert!(result.is_ok());
2151        assert_eq!(result.unwrap().amendments.len(), 1);
2152    }
2153
2154    #[test]
2155    fn parse_amendment_response_invalid_yaml() {
2156        let client = make_client();
2157        let result = client.parse_amendment_response("not: valid: yaml: [{{");
2158        assert!(result.is_err());
2159    }
2160
2161    #[test]
2162    fn parse_amendment_response_invalid_hash() {
2163        let client = make_client();
2164        let yaml = "amendments:\n  - commit: \"short\"\n    message: \"test\"";
2165        let result = client.parse_amendment_response(yaml);
2166        assert!(result.is_err());
2167    }
2168
2169    // ── validate_beta_header ───────────────────────────────────────
2170
2171    #[test]
2172    fn validate_beta_header_none_passes() {
2173        let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2174        assert!(result.is_ok());
2175    }
2176
2177    #[test]
2178    fn validate_beta_header_unsupported_fails() {
2179        let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2180        let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2181        assert!(result.is_err());
2182    }
2183
2184    // ── ClaudeClient::new / get_ai_client_metadata ─────────────────
2185
2186    #[test]
2187    fn client_metadata() {
2188        let client = make_client();
2189        let metadata = client.get_ai_client_metadata();
2190        assert_eq!(metadata.provider, "Mock");
2191        assert_eq!(metadata.model, "mock-model");
2192    }
2193
2194    // ── property tests ────────────────────────────────────────────
2195
2196    mod prop {
2197        use super::*;
2198        use proptest::prelude::*;
2199
2200        proptest! {
2201            #[test]
2202            fn yaml_response_output_trimmed(s in ".*") {
2203                let client = make_client();
2204                let result = client.extract_yaml_from_response(&s);
2205                prop_assert_eq!(&result, result.trim());
2206            }
2207
2208            #[test]
2209            fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2210                let client = make_client();
2211                let input = format!("amendments:{tail}");
2212                let result = client.extract_yaml_from_response(&input);
2213                prop_assert!(result.starts_with("amendments:"));
2214            }
2215
2216            #[test]
2217            fn check_response_checks_prefix_preserved(tail in ".*") {
2218                let client = make_client();
2219                let input = format!("checks:{tail}");
2220                let result = client.extract_yaml_from_check_response(&input);
2221                prop_assert!(result.starts_with("checks:"));
2222            }
2223
2224            #[test]
2225            fn yaml_fenced_block_strips_fences(
2226                content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2227            ) {
2228                let client = make_client();
2229                let input = format!("```yaml\n{content}\n```");
2230                let result = client.extract_yaml_from_response(&input);
2231                prop_assert!(!result.contains("```"));
2232            }
2233        }
2234    }
2235
2236    // ── ConfigurableMockAiClient tests ──────────────────────────────
2237
2238    fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2239        ClaudeClient::new(Box::new(
2240            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2241        ))
2242    }
2243
2244    fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2245        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2246        use crate::git::commit::FileChanges;
2247        use crate::git::{CommitAnalysis, CommitInfo};
2248
2249        let diff_path = dir.path().join("0.diff");
2250        std::fs::write(&diff_path, "+added line\n").unwrap();
2251
2252        crate::data::RepositoryView {
2253            versions: None,
2254            explanation: FieldExplanation::default(),
2255            working_directory: WorkingDirectoryInfo {
2256                clean: true,
2257                untracked_changes: Vec::new(),
2258            },
2259            remotes: Vec::new(),
2260            ai: AiInfo {
2261                scratch: String::new(),
2262            },
2263            branch_info: None,
2264            pr_template: None,
2265            pr_template_location: None,
2266            branch_prs: None,
2267            commits: vec![CommitInfo {
2268                hash: format!("{:0>40}", 0),
2269                author: "Test <test@test.com>".to_string(),
2270                date: chrono::Utc::now().fixed_offset(),
2271                original_message: "feat(test): add something".to_string(),
2272                in_main_branches: Vec::new(),
2273                analysis: CommitAnalysis {
2274                    detected_type: "feat".to_string(),
2275                    detected_scope: "test".to_string(),
2276                    proposed_message: "feat(test): add something".to_string(),
2277                    file_changes: FileChanges {
2278                        total_files: 1,
2279                        files_added: 1,
2280                        files_deleted: 0,
2281                        file_list: Vec::new(),
2282                    },
2283                    diff_summary: "file.rs | 1 +".to_string(),
2284                    diff_file: diff_path.to_string_lossy().to_string(),
2285                    file_diffs: Vec::new(),
2286                },
2287            }],
2288        }
2289    }
2290
2291    fn valid_check_yaml() -> String {
2292        format!(
2293            "checks:\n  - commit: \"{hash}\"\n    passes: true\n    issues: []\n",
2294            hash = format!("{:0>40}", 0)
2295        )
2296    }
2297
2298    #[tokio::test]
2299    async fn send_message_propagates_ai_error() {
2300        let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2301        let result = client.send_message("sys", "usr").await;
2302        assert!(result.is_err());
2303        assert!(result.unwrap_err().to_string().contains("mock error"));
2304    }
2305
2306    #[tokio::test]
2307    async fn check_commits_succeeds_after_request_error() {
2308        let dir = tempfile::tempdir().unwrap();
2309        let repo_view = make_test_repo_view(&dir);
2310        // First attempt: request error; retries return valid response.
2311        let client = make_configurable_client(vec![
2312            Err(anyhow::anyhow!("rate limit")),
2313            Ok(valid_check_yaml()),
2314            Ok(valid_check_yaml()),
2315        ]);
2316        let result = client
2317            .check_commits_with_scopes(&repo_view, None, &[], false)
2318            .await;
2319        assert!(result.is_ok());
2320    }
2321
2322    #[tokio::test]
2323    async fn check_commits_succeeds_after_parse_error() {
2324        let dir = tempfile::tempdir().unwrap();
2325        let repo_view = make_test_repo_view(&dir);
2326        // First attempt: AI returns malformed YAML; retry succeeds.
2327        let client = make_configurable_client(vec![
2328            Ok("not: valid: yaml: [[".to_string()),
2329            Ok(valid_check_yaml()),
2330            Ok(valid_check_yaml()),
2331        ]);
2332        let result = client
2333            .check_commits_with_scopes(&repo_view, None, &[], false)
2334            .await;
2335        assert!(result.is_ok());
2336    }
2337
2338    #[tokio::test]
2339    async fn check_commits_fails_after_all_retries_exhausted() {
2340        let dir = tempfile::tempdir().unwrap();
2341        let repo_view = make_test_repo_view(&dir);
2342        let client = make_configurable_client(vec![
2343            Err(anyhow::anyhow!("first failure")),
2344            Err(anyhow::anyhow!("second failure")),
2345            Err(anyhow::anyhow!("final failure")),
2346        ]);
2347        let result = client
2348            .check_commits_with_scopes(&repo_view, None, &[], false)
2349            .await;
2350        assert!(result.is_err());
2351    }
2352
2353    #[tokio::test]
2354    async fn check_commits_fails_when_all_parses_fail() {
2355        let dir = tempfile::tempdir().unwrap();
2356        let repo_view = make_test_repo_view(&dir);
2357        let client = make_configurable_client(vec![
2358            Ok("bad yaml [[".to_string()),
2359            Ok("bad yaml [[".to_string()),
2360            Ok("bad yaml [[".to_string()),
2361        ]);
2362        let result = client
2363            .check_commits_with_scopes(&repo_view, None, &[], false)
2364            .await;
2365        assert!(result.is_err());
2366    }
2367
2368    // ── split dispatch tests ─────────────────────────────────────
2369
2370    /// Creates a mock client with a constrained context window.
2371    ///
2372    /// The window is large enough that a single-file chunk fits, but too
2373    /// small for both files together (including system prompt overhead).
2374    fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2375        // Context of 50k with more conservative token estimation (2.5 chars/token
2376        // vs 3.5) ensures per-file diffs fit in chunks without placeholders while
2377        // still being large enough to trigger split dispatch for multiple files.
2378        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2379            .with_context_length(50_000);
2380        ClaudeClient::new(Box::new(mock))
2381    }
2382
2383    /// Like [`make_small_context_client`] but also returns a handle to inspect
2384    /// how many mock responses remain unconsumed after the test runs.
2385    fn make_small_context_client_tracked(
2386        responses: Vec<Result<String>>,
2387    ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2388        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2389            .with_context_length(50_000);
2390        let handle = mock.response_handle();
2391        (ClaudeClient::new(Box::new(mock)), handle)
2392    }
2393
2394    /// Creates a repo view with per-file diffs large enough to exceed the
2395    /// constrained context window, ensuring the split dispatch path triggers.
2396    fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2397        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2398        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2399        use crate::git::{CommitAnalysis, CommitInfo};
2400
2401        let hash = "a".repeat(40);
2402
2403        // Write a full (flat) diff file large enough to bust the budget.
2404        // With 50k context / 2.5 chars-per-token / 1.2 margin, available ≈ 41k tokens.
2405        // 120k chars → ~57,600 tokens → well over budget.
2406        let full_diff = "x".repeat(120_000);
2407        let flat_diff_path = dir.path().join("full.diff");
2408        std::fs::write(&flat_diff_path, &full_diff).unwrap();
2409
2410        // Write two large per-file diff files (~30K chars each ≈ 14,400 tokens with
2411        // conservative 2.5 chars/token * 1.2 margin estimation)
2412        let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2413        let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2414
2415        let path_a = dir.path().join("0000.diff");
2416        let path_b = dir.path().join("0001.diff");
2417        std::fs::write(&path_a, &diff_a).unwrap();
2418        std::fs::write(&path_b, &diff_b).unwrap();
2419
2420        crate::data::RepositoryView {
2421            versions: None,
2422            explanation: FieldExplanation::default(),
2423            working_directory: WorkingDirectoryInfo {
2424                clean: true,
2425                untracked_changes: Vec::new(),
2426            },
2427            remotes: Vec::new(),
2428            ai: AiInfo {
2429                scratch: String::new(),
2430            },
2431            branch_info: None,
2432            pr_template: None,
2433            pr_template_location: None,
2434            branch_prs: None,
2435            commits: vec![CommitInfo {
2436                hash,
2437                author: "Test <test@test.com>".to_string(),
2438                date: chrono::Utc::now().fixed_offset(),
2439                original_message: "feat(test): large commit".to_string(),
2440                in_main_branches: Vec::new(),
2441                analysis: CommitAnalysis {
2442                    detected_type: "feat".to_string(),
2443                    detected_scope: "test".to_string(),
2444                    proposed_message: "feat(test): large commit".to_string(),
2445                    file_changes: FileChanges {
2446                        total_files: 2,
2447                        files_added: 2,
2448                        files_deleted: 0,
2449                        file_list: vec![
2450                            FileChange {
2451                                status: "A".to_string(),
2452                                file: "src/a.rs".to_string(),
2453                            },
2454                            FileChange {
2455                                status: "A".to_string(),
2456                                file: "src/b.rs".to_string(),
2457                            },
2458                        ],
2459                    },
2460                    diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2461                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2462                    file_diffs: vec![
2463                        FileDiffRef {
2464                            path: "src/a.rs".to_string(),
2465                            diff_file: path_a.to_string_lossy().to_string(),
2466                            byte_len: diff_a.len(),
2467                        },
2468                        FileDiffRef {
2469                            path: "src/b.rs".to_string(),
2470                            diff_file: path_b.to_string_lossy().to_string(),
2471                            byte_len: diff_b.len(),
2472                        },
2473                    ],
2474                },
2475            }],
2476        }
2477    }
2478
2479    fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2480        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"{message}\"")
2481    }
2482
2483    #[tokio::test]
2484    async fn generate_amendments_split_dispatch() {
2485        let dir = tempfile::tempdir().unwrap();
2486        let repo_view = make_large_diff_repo_view(&dir);
2487        let hash = "a".repeat(40);
2488
2489        // Responses: chunk 1 + chunk 2 + merge pass
2490        let client = make_small_context_client(vec![
2491            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2492            Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2493            Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2494        ]);
2495
2496        let result = client
2497            .generate_amendments_with_options(&repo_view, false)
2498            .await;
2499
2500        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2501        let amendments = result.unwrap();
2502        assert_eq!(amendments.amendments.len(), 1);
2503        assert_eq!(amendments.amendments[0].commit, hash);
2504        assert!(amendments.amendments[0]
2505            .message
2506            .contains("add a.rs and b.rs"));
2507    }
2508
2509    #[tokio::test]
2510    async fn generate_amendments_split_chunk_failure() {
2511        let dir = tempfile::tempdir().unwrap();
2512        let repo_view = make_large_diff_repo_view(&dir);
2513        let hash = "a".repeat(40);
2514
2515        // First chunk succeeds, second chunk fails
2516        let client = make_small_context_client(vec![
2517            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2518            Err(anyhow::anyhow!("rate limit exceeded")),
2519        ]);
2520
2521        let result = client
2522            .generate_amendments_with_options(&repo_view, false)
2523            .await;
2524
2525        assert!(result.is_err());
2526    }
2527
2528    #[tokio::test]
2529    async fn generate_amendments_no_split_when_fits() {
2530        let dir = tempfile::tempdir().unwrap();
2531        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2532        let hash = format!("{:0>40}", 0);
2533
2534        // Only one response needed — no split dispatch
2535        let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2536            &hash,
2537            "feat(test): improved message",
2538        ))]);
2539
2540        let result = client
2541            .generate_amendments_with_options(&repo_view, false)
2542            .await;
2543
2544        assert!(result.is_ok());
2545        assert_eq!(result.unwrap().amendments.len(), 1);
2546    }
2547
2548    // ── check split dispatch tests ──────────────────────────────
2549
2550    fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2551        format!(
2552            "checks:\n  - commit: \"{hash}\"\n    passes: {passes}\n    issues: []\n    summary: \"test summary\"\n"
2553        )
2554    }
2555
2556    fn valid_check_yaml_with_issues(hash: &str) -> String {
2557        format!(
2558            concat!(
2559                "checks:\n",
2560                "  - commit: \"{hash}\"\n",
2561                "    passes: false\n",
2562                "    issues:\n",
2563                "      - severity: error\n",
2564                "        section: \"Subject Line\"\n",
2565                "        rule: \"subject-too-long\"\n",
2566                "        explanation: \"Subject exceeds 72 characters\"\n",
2567                "    suggestion:\n",
2568                "      message: \"feat(test): shorter subject\"\n",
2569                "      explanation: \"Shortened subject line\"\n",
2570                "    summary: \"Large commit with issues\"\n",
2571            ),
2572            hash = hash,
2573        )
2574    }
2575
2576    fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2577        format!(
2578            concat!(
2579                "checks:\n",
2580                "  - commit: \"{hash}\"\n",
2581                "    passes: true\n",
2582                "    issues: []\n",
2583                "    summary: \"chunk summary\"\n",
2584            ),
2585            hash = hash,
2586        )
2587    }
2588
2589    #[tokio::test]
2590    async fn check_commits_split_dispatch() {
2591        let dir = tempfile::tempdir().unwrap();
2592        let repo_view = make_large_diff_repo_view(&dir);
2593        let hash = "a".repeat(40);
2594
2595        // Responses: chunk 1 (issues + suggestion) + chunk 2 (issues + suggestion) + merge pass
2596        let client = make_small_context_client(vec![
2597            Ok(valid_check_yaml_with_issues(&hash)),
2598            Ok(valid_check_yaml_with_issues(&hash)),
2599            Ok(valid_check_yaml_with_issues(&hash)), // merge pass response
2600        ]);
2601
2602        let result = client
2603            .check_commits_with_scopes(&repo_view, None, &[], true)
2604            .await;
2605
2606        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2607        let report = result.unwrap();
2608        assert_eq!(report.commits.len(), 1);
2609        assert!(!report.commits[0].passes);
2610        // Dedup: both chunks report the same (rule, severity, section), so only 1 unique issue
2611        assert_eq!(report.commits[0].issues.len(), 1);
2612        assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2613    }
2614
2615    #[tokio::test]
2616    async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2617        let dir = tempfile::tempdir().unwrap();
2618        let repo_view = make_large_diff_repo_view(&dir);
2619        let hash = "a".repeat(40);
2620
2621        // Responses: chunk 1 + chunk 2, both passing with no suggestions
2622        // No merge pass needed — only 2 responses
2623        let client = make_small_context_client(vec![
2624            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2625            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2626        ]);
2627
2628        let result = client
2629            .check_commits_with_scopes(&repo_view, None, &[], false)
2630            .await;
2631
2632        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2633        let report = result.unwrap();
2634        assert_eq!(report.commits.len(), 1);
2635        assert!(report.commits[0].passes);
2636        assert!(report.commits[0].issues.is_empty());
2637        assert!(report.commits[0].suggestion.is_none());
2638        // First non-None summary from chunks
2639        assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2640    }
2641
2642    #[tokio::test]
2643    async fn check_commits_split_chunk_failure() {
2644        let dir = tempfile::tempdir().unwrap();
2645        let repo_view = make_large_diff_repo_view(&dir);
2646        let hash = "a".repeat(40);
2647
2648        // First chunk succeeds, second chunk fails
2649        let client = make_small_context_client(vec![
2650            Ok(valid_check_yaml_for(&hash, true)),
2651            Err(anyhow::anyhow!("rate limit exceeded")),
2652        ]);
2653
2654        let result = client
2655            .check_commits_with_scopes(&repo_view, None, &[], false)
2656            .await;
2657
2658        assert!(result.is_err());
2659    }
2660
2661    #[tokio::test]
2662    async fn check_commits_no_split_when_fits() {
2663        let dir = tempfile::tempdir().unwrap();
2664        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2665        let hash = format!("{:0>40}", 0);
2666
2667        // Only one response needed — no split dispatch
2668        let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2669
2670        let result = client
2671            .check_commits_with_scopes(&repo_view, None, &[], false)
2672            .await;
2673
2674        assert!(result.is_ok());
2675        assert_eq!(result.unwrap().commits.len(), 1);
2676    }
2677
2678    #[tokio::test]
2679    async fn check_commits_split_dedup_across_chunks() {
2680        let dir = tempfile::tempdir().unwrap();
2681        let repo_view = make_large_diff_repo_view(&dir);
2682        let hash = "a".repeat(40);
2683
2684        // Chunk 1: two issues (error + warning)
2685        let chunk1 = format!(
2686            concat!(
2687                "checks:\n",
2688                "  - commit: \"{hash}\"\n",
2689                "    passes: false\n",
2690                "    issues:\n",
2691                "      - severity: error\n",
2692                "        section: \"Subject Line\"\n",
2693                "        rule: \"subject-too-long\"\n",
2694                "        explanation: \"Subject exceeds 72 characters\"\n",
2695                "      - severity: warning\n",
2696                "        section: \"Content\"\n",
2697                "        rule: \"body-required\"\n",
2698                "        explanation: \"Large change needs body\"\n",
2699            ),
2700            hash = hash,
2701        );
2702
2703        // Chunk 2: same error (different wording) + new info issue
2704        let chunk2 = format!(
2705            concat!(
2706                "checks:\n",
2707                "  - commit: \"{hash}\"\n",
2708                "    passes: false\n",
2709                "    issues:\n",
2710                "      - severity: error\n",
2711                "        section: \"Subject Line\"\n",
2712                "        rule: \"subject-too-long\"\n",
2713                "        explanation: \"Subject line is too long\"\n",
2714                "      - severity: info\n",
2715                "        section: \"Style\"\n",
2716                "        rule: \"scope-suggestion\"\n",
2717                "        explanation: \"Consider more specific scope\"\n",
2718            ),
2719            hash = hash,
2720        );
2721
2722        // No suggestions → no merge pass needed
2723        let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2724
2725        let result = client
2726            .check_commits_with_scopes(&repo_view, None, &[], false)
2727            .await;
2728
2729        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2730        let report = result.unwrap();
2731        assert_eq!(report.commits.len(), 1);
2732        assert!(!report.commits[0].passes);
2733        // 3 unique issues: subject-too-long, body-required, scope-suggestion
2734        // (subject-too-long appears in both chunks but deduped)
2735        assert_eq!(report.commits[0].issues.len(), 3);
2736    }
2737
2738    #[tokio::test]
2739    async fn check_commits_split_passes_only_when_all_chunks_pass() {
2740        let dir = tempfile::tempdir().unwrap();
2741        let repo_view = make_large_diff_repo_view(&dir);
2742        let hash = "a".repeat(40);
2743
2744        // Chunk 1 passes, chunk 2 fails
2745        let client = make_small_context_client(vec![
2746            Ok(valid_check_yaml_for(&hash, true)),
2747            Ok(valid_check_yaml_for(&hash, false)),
2748        ]);
2749
2750        let result = client
2751            .check_commits_with_scopes(&repo_view, None, &[], false)
2752            .await;
2753
2754        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2755        let report = result.unwrap();
2756        assert!(
2757            !report.commits[0].passes,
2758            "should fail when any chunk fails"
2759        );
2760    }
2761
2762    // ── multi-commit and PR generation paths ──────────────────────
2763
2764    /// Creates a repo view with two small commits (fits budget without split dispatch).
2765    fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2766        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2767        use crate::git::commit::FileChanges;
2768        use crate::git::{CommitAnalysis, CommitInfo};
2769
2770        let diff_a = dir.path().join("0.diff");
2771        let diff_b = dir.path().join("1.diff");
2772        std::fs::write(&diff_a, "+line a\n").unwrap();
2773        std::fs::write(&diff_b, "+line b\n").unwrap();
2774
2775        let hash_a = "a".repeat(40);
2776        let hash_b = "b".repeat(40);
2777
2778        crate::data::RepositoryView {
2779            versions: None,
2780            explanation: FieldExplanation::default(),
2781            working_directory: WorkingDirectoryInfo {
2782                clean: true,
2783                untracked_changes: Vec::new(),
2784            },
2785            remotes: Vec::new(),
2786            ai: AiInfo {
2787                scratch: String::new(),
2788            },
2789            branch_info: None,
2790            pr_template: None,
2791            pr_template_location: None,
2792            branch_prs: None,
2793            commits: vec![
2794                CommitInfo {
2795                    hash: hash_a,
2796                    author: "Test <test@test.com>".to_string(),
2797                    date: chrono::Utc::now().fixed_offset(),
2798                    original_message: "feat(a): add a".to_string(),
2799                    in_main_branches: Vec::new(),
2800                    analysis: CommitAnalysis {
2801                        detected_type: "feat".to_string(),
2802                        detected_scope: "a".to_string(),
2803                        proposed_message: "feat(a): add a".to_string(),
2804                        file_changes: FileChanges {
2805                            total_files: 1,
2806                            files_added: 1,
2807                            files_deleted: 0,
2808                            file_list: Vec::new(),
2809                        },
2810                        diff_summary: "a.rs | 1 +".to_string(),
2811                        diff_file: diff_a.to_string_lossy().to_string(),
2812                        file_diffs: Vec::new(),
2813                    },
2814                },
2815                CommitInfo {
2816                    hash: hash_b,
2817                    author: "Test <test@test.com>".to_string(),
2818                    date: chrono::Utc::now().fixed_offset(),
2819                    original_message: "feat(b): add b".to_string(),
2820                    in_main_branches: Vec::new(),
2821                    analysis: CommitAnalysis {
2822                        detected_type: "feat".to_string(),
2823                        detected_scope: "b".to_string(),
2824                        proposed_message: "feat(b): add b".to_string(),
2825                        file_changes: FileChanges {
2826                            total_files: 1,
2827                            files_added: 1,
2828                            files_deleted: 0,
2829                            file_list: Vec::new(),
2830                        },
2831                        diff_summary: "b.rs | 1 +".to_string(),
2832                        diff_file: diff_b.to_string_lossy().to_string(),
2833                        file_diffs: Vec::new(),
2834                    },
2835                },
2836            ],
2837        }
2838    }
2839
2840    #[tokio::test]
2841    async fn generate_amendments_multi_commit() {
2842        let dir = tempfile::tempdir().unwrap();
2843        let repo_view = make_multi_commit_repo_view(&dir);
2844        let hash_a = "a".repeat(40);
2845        let hash_b = "b".repeat(40);
2846
2847        let response = format!(
2848            concat!(
2849                "amendments:\n",
2850                "  - commit: \"{hash_a}\"\n",
2851                "    message: \"feat(a): improved a\"\n",
2852                "  - commit: \"{hash_b}\"\n",
2853                "    message: \"feat(b): improved b\"\n",
2854            ),
2855            hash_a = hash_a,
2856            hash_b = hash_b,
2857        );
2858        let client = make_configurable_client(vec![Ok(response)]);
2859
2860        let result = client
2861            .generate_amendments_with_options(&repo_view, false)
2862            .await;
2863
2864        assert!(
2865            result.is_ok(),
2866            "multi-commit amendment failed: {:?}",
2867            result.err()
2868        );
2869        let amendments = result.unwrap();
2870        assert_eq!(amendments.amendments.len(), 2);
2871    }
2872
2873    #[tokio::test]
2874    async fn generate_contextual_amendments_multi_commit() {
2875        let dir = tempfile::tempdir().unwrap();
2876        let repo_view = make_multi_commit_repo_view(&dir);
2877        let hash_a = "a".repeat(40);
2878        let hash_b = "b".repeat(40);
2879
2880        let response = format!(
2881            concat!(
2882                "amendments:\n",
2883                "  - commit: \"{hash_a}\"\n",
2884                "    message: \"feat(a): improved a\"\n",
2885                "  - commit: \"{hash_b}\"\n",
2886                "    message: \"feat(b): improved b\"\n",
2887            ),
2888            hash_a = hash_a,
2889            hash_b = hash_b,
2890        );
2891        let client = make_configurable_client(vec![Ok(response)]);
2892        let context = crate::data::context::CommitContext::default();
2893
2894        let result = client
2895            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2896            .await;
2897
2898        assert!(
2899            result.is_ok(),
2900            "multi-commit contextual amendment failed: {:?}",
2901            result.err()
2902        );
2903        let amendments = result.unwrap();
2904        assert_eq!(amendments.amendments.len(), 2);
2905    }
2906
2907    #[tokio::test]
2908    async fn generate_pr_content_succeeds() {
2909        let dir = tempfile::tempdir().unwrap();
2910        let repo_view = make_test_repo_view(&dir);
2911
2912        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2913        let client = make_configurable_client(vec![Ok(response.to_string())]);
2914
2915        let result = client.generate_pr_content(&repo_view, "").await;
2916
2917        assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2918        let pr = result.unwrap();
2919        assert_eq!(pr.title, "feat: add something");
2920        assert_eq!(pr.description, "Adds a new feature.");
2921    }
2922
2923    #[tokio::test]
2924    async fn generate_pr_content_with_context_succeeds() {
2925        let dir = tempfile::tempdir().unwrap();
2926        let repo_view = make_test_repo_view(&dir);
2927        let context = crate::data::context::CommitContext::default();
2928
2929        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2930        let client = make_configurable_client(vec![Ok(response.to_string())]);
2931
2932        let result = client
2933            .generate_pr_content_with_context(&repo_view, "", &context)
2934            .await;
2935
2936        assert!(
2937            result.is_ok(),
2938            "PR generation with context failed: {:?}",
2939            result.err()
2940        );
2941        let pr = result.unwrap();
2942        assert_eq!(pr.title, "feat: add something");
2943    }
2944
2945    #[tokio::test]
2946    async fn check_commits_multi_commit() {
2947        let dir = tempfile::tempdir().unwrap();
2948        let repo_view = make_multi_commit_repo_view(&dir);
2949        let hash_a = "a".repeat(40);
2950        let hash_b = "b".repeat(40);
2951
2952        let response = format!(
2953            concat!(
2954                "checks:\n",
2955                "  - commit: \"{hash_a}\"\n",
2956                "    passes: true\n",
2957                "    issues: []\n",
2958                "  - commit: \"{hash_b}\"\n",
2959                "    passes: true\n",
2960                "    issues: []\n",
2961            ),
2962            hash_a = hash_a,
2963            hash_b = hash_b,
2964        );
2965        let client = make_configurable_client(vec![Ok(response)]);
2966
2967        let result = client
2968            .check_commits_with_scopes(&repo_view, None, &[], false)
2969            .await;
2970
2971        assert!(
2972            result.is_ok(),
2973            "multi-commit check failed: {:?}",
2974            result.err()
2975        );
2976        let report = result.unwrap();
2977        assert_eq!(report.commits.len(), 2);
2978        assert!(report.commits[0].passes);
2979        assert!(report.commits[1].passes);
2980    }
2981
2982    // ── Multi-commit split dispatch helpers ──────────────────────────
2983
2984    /// Creates a repo view with two large-diff commits whose combined view
2985    /// exceeds the constrained 25KB context window.
2986    fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2987        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2988        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2989        use crate::git::{CommitAnalysis, CommitInfo};
2990
2991        let hash_a = "a".repeat(40);
2992        let hash_b = "b".repeat(40);
2993
2994        // Write flat diff files large enough to bust the 50K-token budget when combined.
2995        // Each 60k chars ≈ 28,800 tokens; combined ≈ 57,600 > 41,808 available.
2996        let diff_content_a = "x".repeat(60_000);
2997        let diff_content_b = "y".repeat(60_000);
2998        let flat_a = dir.path().join("flat_a.diff");
2999        let flat_b = dir.path().join("flat_b.diff");
3000        std::fs::write(&flat_a, &diff_content_a).unwrap();
3001        std::fs::write(&flat_b, &diff_content_b).unwrap();
3002
3003        // Write per-file diff files for split dispatch
3004        let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3005        let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3006        let per_file_a = dir.path().join("pf_a.diff");
3007        let per_file_b = dir.path().join("pf_b.diff");
3008        std::fs::write(&per_file_a, &file_diff_a).unwrap();
3009        std::fs::write(&per_file_b, &file_diff_b).unwrap();
3010
3011        crate::data::RepositoryView {
3012            versions: None,
3013            explanation: FieldExplanation::default(),
3014            working_directory: WorkingDirectoryInfo {
3015                clean: true,
3016                untracked_changes: Vec::new(),
3017            },
3018            remotes: Vec::new(),
3019            ai: AiInfo {
3020                scratch: String::new(),
3021            },
3022            branch_info: None,
3023            pr_template: None,
3024            pr_template_location: None,
3025            branch_prs: None,
3026            commits: vec![
3027                CommitInfo {
3028                    hash: hash_a,
3029                    author: "Test <test@test.com>".to_string(),
3030                    date: chrono::Utc::now().fixed_offset(),
3031                    original_message: "feat(a): add module a".to_string(),
3032                    in_main_branches: Vec::new(),
3033                    analysis: CommitAnalysis {
3034                        detected_type: "feat".to_string(),
3035                        detected_scope: "a".to_string(),
3036                        proposed_message: "feat(a): add module a".to_string(),
3037                        file_changes: FileChanges {
3038                            total_files: 1,
3039                            files_added: 1,
3040                            files_deleted: 0,
3041                            file_list: vec![FileChange {
3042                                status: "A".to_string(),
3043                                file: "src/a.rs".to_string(),
3044                            }],
3045                        },
3046                        diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3047                        diff_file: flat_a.to_string_lossy().to_string(),
3048                        file_diffs: vec![FileDiffRef {
3049                            path: "src/a.rs".to_string(),
3050                            diff_file: per_file_a.to_string_lossy().to_string(),
3051                            byte_len: file_diff_a.len(),
3052                        }],
3053                    },
3054                },
3055                CommitInfo {
3056                    hash: hash_b,
3057                    author: "Test <test@test.com>".to_string(),
3058                    date: chrono::Utc::now().fixed_offset(),
3059                    original_message: "feat(b): add module b".to_string(),
3060                    in_main_branches: Vec::new(),
3061                    analysis: CommitAnalysis {
3062                        detected_type: "feat".to_string(),
3063                        detected_scope: "b".to_string(),
3064                        proposed_message: "feat(b): add module b".to_string(),
3065                        file_changes: FileChanges {
3066                            total_files: 1,
3067                            files_added: 1,
3068                            files_deleted: 0,
3069                            file_list: vec![FileChange {
3070                                status: "A".to_string(),
3071                                file: "src/b.rs".to_string(),
3072                            }],
3073                        },
3074                        diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3075                        diff_file: flat_b.to_string_lossy().to_string(),
3076                        file_diffs: vec![FileDiffRef {
3077                            path: "src/b.rs".to_string(),
3078                            diff_file: per_file_b.to_string_lossy().to_string(),
3079                            byte_len: file_diff_b.len(),
3080                        }],
3081                    },
3082                },
3083            ],
3084        }
3085    }
3086
3087    fn valid_pr_yaml(title: &str, description: &str) -> String {
3088        format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3089    }
3090
3091    // ── Multi-commit amendment split dispatch tests ──────────────────
3092
3093    #[tokio::test]
3094    async fn generate_amendments_multi_commit_split_dispatch() {
3095        let dir = tempfile::tempdir().unwrap();
3096        let repo_view = make_large_multi_commit_repo_view(&dir);
3097        let hash_a = "a".repeat(40);
3098        let hash_b = "b".repeat(40);
3099
3100        // Full view exceeds budget → per-commit fallback
3101        // Each commit fits individually (1 file each) → 1 response per commit
3102        let (client, handle) = make_small_context_client_tracked(vec![
3103            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3104            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3105        ]);
3106
3107        let result = client
3108            .generate_amendments_with_options(&repo_view, false)
3109            .await;
3110
3111        assert!(
3112            result.is_ok(),
3113            "multi-commit split dispatch failed: {:?}",
3114            result.err()
3115        );
3116        let amendments = result.unwrap();
3117        assert_eq!(amendments.amendments.len(), 2);
3118        assert_eq!(amendments.amendments[0].commit, hash_a);
3119        assert_eq!(amendments.amendments[1].commit, hash_b);
3120        assert!(amendments.amendments[0].message.contains("improved a"));
3121        assert!(amendments.amendments[1].message.contains("improved b"));
3122        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3123    }
3124
3125    #[tokio::test]
3126    async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3127        let dir = tempfile::tempdir().unwrap();
3128        let repo_view = make_large_multi_commit_repo_view(&dir);
3129        let hash_a = "a".repeat(40);
3130        let hash_b = "b".repeat(40);
3131        let context = crate::data::context::CommitContext::default();
3132
3133        let (client, handle) = make_small_context_client_tracked(vec![
3134            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3135            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3136        ]);
3137
3138        let result = client
3139            .generate_contextual_amendments_with_options(&repo_view, &context, false)
3140            .await;
3141
3142        assert!(
3143            result.is_ok(),
3144            "multi-commit contextual split dispatch failed: {:?}",
3145            result.err()
3146        );
3147        let amendments = result.unwrap();
3148        assert_eq!(amendments.amendments.len(), 2);
3149        assert_eq!(amendments.amendments[0].commit, hash_a);
3150        assert_eq!(amendments.amendments[1].commit, hash_b);
3151        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3152    }
3153
3154    // ── Multi-commit check split dispatch tests ──────────────────────
3155
3156    #[tokio::test]
3157    async fn check_commits_multi_commit_split_dispatch() {
3158        let dir = tempfile::tempdir().unwrap();
3159        let repo_view = make_large_multi_commit_repo_view(&dir);
3160        let hash_a = "a".repeat(40);
3161        let hash_b = "b".repeat(40);
3162
3163        // Full view exceeds budget → per-commit fallback
3164        let (client, handle) = make_small_context_client_tracked(vec![
3165            Ok(valid_check_yaml_for(&hash_a, true)),
3166            Ok(valid_check_yaml_for(&hash_b, true)),
3167        ]);
3168
3169        let result = client
3170            .check_commits_with_scopes(&repo_view, None, &[], false)
3171            .await;
3172
3173        assert!(
3174            result.is_ok(),
3175            "multi-commit check split dispatch failed: {:?}",
3176            result.err()
3177        );
3178        let report = result.unwrap();
3179        assert_eq!(report.commits.len(), 2);
3180        assert!(report.commits[0].passes);
3181        assert!(report.commits[1].passes);
3182        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3183    }
3184
3185    // ── PR split dispatch tests ──────────────────────────────────────
3186
3187    #[tokio::test]
3188    async fn generate_pr_content_split_dispatch() {
3189        let dir = tempfile::tempdir().unwrap();
3190        let repo_view = make_large_diff_repo_view(&dir);
3191
3192        // Single large commit: full view exceeds budget → per-commit fallback
3193        // 1 commit with 2 file chunks → chunk 1 + chunk 2 + chunk merge pass
3194        // Single per-commit result → returned directly (no extra merge)
3195        let (client, handle) = make_small_context_client_tracked(vec![
3196            Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3197            Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3198            Ok(valid_pr_yaml(
3199                "feat(test): add modules",
3200                "Adds a.rs and b.rs",
3201            )),
3202        ]);
3203
3204        let result = client.generate_pr_content(&repo_view, "").await;
3205
3206        assert!(
3207            result.is_ok(),
3208            "PR split dispatch failed: {:?}",
3209            result.err()
3210        );
3211        let pr = result.unwrap();
3212        assert!(pr.title.contains("add modules"));
3213        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3214    }
3215
3216    #[tokio::test]
3217    async fn generate_pr_content_multi_commit_split_dispatch() {
3218        let dir = tempfile::tempdir().unwrap();
3219        let repo_view = make_large_multi_commit_repo_view(&dir);
3220
3221        // Full view exceeds budget → per-commit fallback
3222        // Each commit fits individually → 1 response per commit, then merge pass
3223        let (client, handle) = make_small_context_client_tracked(vec![
3224            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3225            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3226            Ok(valid_pr_yaml(
3227                "feat: add modules a and b",
3228                "Adds both modules",
3229            )),
3230        ]);
3231
3232        let result = client.generate_pr_content(&repo_view, "").await;
3233
3234        assert!(
3235            result.is_ok(),
3236            "PR multi-commit split dispatch failed: {:?}",
3237            result.err()
3238        );
3239        let pr = result.unwrap();
3240        assert!(pr.title.contains("modules"));
3241        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3242    }
3243
3244    #[tokio::test]
3245    async fn generate_pr_content_with_context_split_dispatch() {
3246        let dir = tempfile::tempdir().unwrap();
3247        let repo_view = make_large_multi_commit_repo_view(&dir);
3248        let context = crate::data::context::CommitContext::default();
3249
3250        // Full view exceeds budget → per-commit fallback → merge pass
3251        let (client, handle) = make_small_context_client_tracked(vec![
3252            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3253            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3254            Ok(valid_pr_yaml(
3255                "feat: add modules a and b",
3256                "Adds both modules",
3257            )),
3258        ]);
3259
3260        let result = client
3261            .generate_pr_content_with_context(&repo_view, "", &context)
3262            .await;
3263
3264        assert!(
3265            result.is_ok(),
3266            "PR with context split dispatch failed: {:?}",
3267            result.err()
3268        );
3269        let pr = result.unwrap();
3270        assert!(pr.title.contains("modules"));
3271        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3272    }
3273
3274    // ── prompt-recording split dispatch tests ────────────────────
3275
3276    /// Like [`make_small_context_client_tracked`] but also returns a
3277    /// [`PromptRecordHandle`] for inspecting which prompts were sent.
3278    fn make_small_context_client_with_prompts(
3279        responses: Vec<Result<String>>,
3280    ) -> (
3281        ClaudeClient,
3282        crate::claude::test_utils::ResponseQueueHandle,
3283        crate::claude::test_utils::PromptRecordHandle,
3284    ) {
3285        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3286            .with_context_length(50_000);
3287        let response_handle = mock.response_handle();
3288        let prompt_handle = mock.prompt_handle();
3289        (
3290            ClaudeClient::new(Box::new(mock)),
3291            response_handle,
3292            prompt_handle,
3293        )
3294    }
3295
3296    /// Creates a default-context mock client that also records prompts.
3297    fn make_configurable_client_with_prompts(
3298        responses: Vec<Result<String>>,
3299    ) -> (
3300        ClaudeClient,
3301        crate::claude::test_utils::ResponseQueueHandle,
3302        crate::claude::test_utils::PromptRecordHandle,
3303    ) {
3304        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3305        let response_handle = mock.response_handle();
3306        let prompt_handle = mock.prompt_handle();
3307        (
3308            ClaudeClient::new(Box::new(mock)),
3309            response_handle,
3310            prompt_handle,
3311        )
3312    }
3313
3314    /// Creates a repo view with one commit containing a single large file
3315    /// whose diff exceeds the token budget. Because the per-file diff is
3316    /// loaded as a whole (hunk-level granularity from the packer is lost
3317    /// at the dispatch layer), the split dispatch path will fail with a
3318    /// budget error. This helper exists to test that the error propagates
3319    /// cleanly rather than silently degrading.
3320    fn make_single_oversized_file_repo_view(
3321        dir: &tempfile::TempDir,
3322    ) -> crate::data::RepositoryView {
3323        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3324        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3325        use crate::git::{CommitAnalysis, CommitInfo};
3326
3327        let hash = "c".repeat(40);
3328
3329        // A single file diff large enough (~80K bytes ≈ 25K tokens) to
3330        // exceed the 25K context window budget even for a single chunk.
3331        let diff_content = format!(
3332            "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3333            "x".repeat(80_000)
3334        );
3335
3336        let flat_diff_path = dir.path().join("full.diff");
3337        std::fs::write(&flat_diff_path, &diff_content).unwrap();
3338
3339        let per_file_path = dir.path().join("0000.diff");
3340        std::fs::write(&per_file_path, &diff_content).unwrap();
3341
3342        crate::data::RepositoryView {
3343            versions: None,
3344            explanation: FieldExplanation::default(),
3345            working_directory: WorkingDirectoryInfo {
3346                clean: true,
3347                untracked_changes: Vec::new(),
3348            },
3349            remotes: Vec::new(),
3350            ai: AiInfo {
3351                scratch: String::new(),
3352            },
3353            branch_info: None,
3354            pr_template: None,
3355            pr_template_location: None,
3356            branch_prs: None,
3357            commits: vec![CommitInfo {
3358                hash,
3359                author: "Test <test@test.com>".to_string(),
3360                date: chrono::Utc::now().fixed_offset(),
3361                original_message: "feat(big): add large module".to_string(),
3362                in_main_branches: Vec::new(),
3363                analysis: CommitAnalysis {
3364                    detected_type: "feat".to_string(),
3365                    detected_scope: "big".to_string(),
3366                    proposed_message: "feat(big): add large module".to_string(),
3367                    file_changes: FileChanges {
3368                        total_files: 1,
3369                        files_added: 1,
3370                        files_deleted: 0,
3371                        file_list: vec![FileChange {
3372                            status: "A".to_string(),
3373                            file: "src/big.rs".to_string(),
3374                        }],
3375                    },
3376                    diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3377                    diff_file: flat_diff_path.to_string_lossy().to_string(),
3378                    file_diffs: vec![FileDiffRef {
3379                        path: "src/big.rs".to_string(),
3380                        diff_file: per_file_path.to_string_lossy().to_string(),
3381                        byte_len: diff_content.len(),
3382                    }],
3383                },
3384            }],
3385        }
3386    }
3387
3388    /// A small single-file commit whose diff fits within the token budget.
3389    ///
3390    /// Exercises the non-split path: `generate_amendments_with_options` →
3391    /// `try_full_diff_budget` succeeds → single AI request → amendment
3392    /// returned directly. Verifies exactly one request is made and the
3393    /// user prompt contains the actual diff content.
3394    #[tokio::test]
3395    async fn amendment_single_file_under_budget_no_split() {
3396        let dir = tempfile::tempdir().unwrap();
3397        let repo_view = make_test_repo_view(&dir);
3398        let hash = format!("{:0>40}", 0);
3399
3400        let (client, response_handle, prompt_handle) =
3401            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3402                &hash,
3403                "feat(test): improved message",
3404            ))]);
3405
3406        let result = client
3407            .generate_amendments_with_options(&repo_view, false)
3408            .await;
3409
3410        assert!(result.is_ok());
3411        assert_eq!(result.unwrap().amendments.len(), 1);
3412        assert_eq!(response_handle.remaining(), 0);
3413
3414        let prompts = prompt_handle.prompts();
3415        assert_eq!(
3416            prompts.len(),
3417            1,
3418            "expected exactly one AI request, no split"
3419        );
3420
3421        let (_, user_prompt) = &prompts[0];
3422        assert!(
3423            user_prompt.contains("added line"),
3424            "user prompt should contain the diff content"
3425        );
3426    }
3427
3428    /// A two-file commit that exceeds the token budget when combined.
3429    ///
3430    /// Exercises the file-level split path: `generate_amendments_with_options`
3431    /// → `try_full_diff_budget` fails → `generate_amendment_for_commit` →
3432    /// `try_full_diff_budget` fails again → `generate_amendment_split` →
3433    /// `pack_file_diffs` creates 2 chunks (one file each) → 2 AI requests
3434    /// → `merge_amendment_chunks` reduce pass → 1 merged amendment.
3435    ///
3436    /// Verifies that each chunk's user prompt contains only its file's diff
3437    /// content, and the merge prompt contains both partial amendment messages.
3438    #[tokio::test]
3439    async fn amendment_two_chunks_prompt_content() {
3440        let dir = tempfile::tempdir().unwrap();
3441        let repo_view = make_large_diff_repo_view(&dir);
3442        let hash = "a".repeat(40);
3443
3444        let (client, response_handle, prompt_handle) =
3445            make_small_context_client_with_prompts(vec![
3446                Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3447                Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3448                Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3449            ]);
3450
3451        let result = client
3452            .generate_amendments_with_options(&repo_view, false)
3453            .await;
3454
3455        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3456        let amendments = result.unwrap();
3457        assert_eq!(amendments.amendments.len(), 1);
3458        assert!(amendments.amendments[0]
3459            .message
3460            .contains("add a.rs and b.rs"));
3461        assert_eq!(response_handle.remaining(), 0);
3462
3463        let prompts = prompt_handle.prompts();
3464        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3465
3466        // Chunk 1 should contain file-a diff content (repeated 'a' chars)
3467        let (_, chunk1_user) = &prompts[0];
3468        assert!(
3469            chunk1_user.contains("aaa"),
3470            "chunk 1 prompt should contain file-a diff content"
3471        );
3472
3473        // Chunk 2 should contain file-b diff content (repeated 'b' chars)
3474        let (_, chunk2_user) = &prompts[1];
3475        assert!(
3476            chunk2_user.contains("bbb"),
3477            "chunk 2 prompt should contain file-b diff content"
3478        );
3479
3480        // Merge pass: system prompt is the synthesis prompt
3481        let (merge_sys, merge_user) = &prompts[2];
3482        assert!(
3483            merge_sys.contains("synthesiz"),
3484            "merge system prompt should contain synthesis instructions"
3485        );
3486        // Merge user prompt should contain both partial messages
3487        assert!(
3488            merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3489            "merge user prompt should contain both partial amendment messages"
3490        );
3491    }
3492
3493    /// A single file whose diff exceeds the budget even after split dispatch.
3494    ///
3495    /// Exercises the budget-error path: `generate_amendment_for_commit` →
3496    /// budget exceeded → `generate_amendment_split` → `pack_file_diffs`
3497    /// plans hunk-level chunks → but `from_commit_info_partial` loads the
3498    /// full per-file diff (deduplicates the repeated path) →
3499    /// Oversized files that can't be split get placeholders and proceed.
3500    ///
3501    /// Verifies that files too large for the budget are replaced with
3502    /// placeholder text indicating the file was omitted, rather than
3503    /// failing with a "prompt too large" error.
3504    #[tokio::test]
3505    async fn amendment_single_oversized_file_gets_placeholder() {
3506        let dir = tempfile::tempdir().unwrap();
3507        let repo_view = make_single_oversized_file_repo_view(&dir);
3508        let hash = "c".repeat(40);
3509
3510        // The file is too large for the full budget but gets a placeholder.
3511        // With 50k context, the placeholder is small enough to fit in a
3512        // single request. Provide a second response in case the system prompt
3513        // is large enough to trigger split dispatch.
3514        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3515            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3516            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3517        ]);
3518
3519        let result = client
3520            .generate_amendments_with_options(&repo_view, false)
3521            .await;
3522
3523        // Should succeed (either single request or split with placeholder)
3524        assert!(
3525            result.is_ok(),
3526            "expected success with placeholder, got: {result:?}"
3527        );
3528
3529        // One request (placeholder makes it fit in single request)
3530        assert!(
3531            prompt_handle.request_count() >= 1,
3532            "expected at least 1 request, got {}",
3533            prompt_handle.request_count()
3534        );
3535    }
3536
3537    /// A two-chunk split where the second chunk's AI request fails.
3538    ///
3539    /// Exercises the error-propagation path within `generate_amendment_split`:
3540    /// chunk 1 succeeds → chunk 2 returns `Err` → the `?` operator in the
3541    /// loop body propagates the error immediately, skipping the merge pass.
3542    ///
3543    /// Verifies that exactly 2 requests are recorded (no further processing)
3544    /// and the overall result is `Err` (no silent degradation).
3545    #[tokio::test]
3546    async fn amendment_chunk_failure_stops_dispatch() {
3547        let dir = tempfile::tempdir().unwrap();
3548        let repo_view = make_large_diff_repo_view(&dir);
3549        let hash = "a".repeat(40);
3550
3551        // First chunk succeeds, second chunk fails
3552        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3553            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3554            Err(anyhow::anyhow!("rate limit exceeded")),
3555        ]);
3556
3557        let result = client
3558            .generate_amendments_with_options(&repo_view, false)
3559            .await;
3560
3561        assert!(result.is_err());
3562
3563        // Exactly 2 requests: chunk 1 (success) + chunk 2 (failure)
3564        let prompts = prompt_handle.prompts();
3565        assert_eq!(
3566            prompts.len(),
3567            2,
3568            "should stop after the failing chunk, got {} requests",
3569            prompts.len()
3570        );
3571
3572        // The first request should reference one of the files
3573        let (_, first_user) = &prompts[0];
3574        assert!(
3575            first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3576            "first chunk prompt should reference a file"
3577        );
3578    }
3579
3580    /// Two-chunk amendment split dispatch, focused on the reduce pass inputs.
3581    ///
3582    /// Exercises `merge_amendment_chunks` which calls
3583    /// `generate_chunk_merge_user_prompt` to assemble the merge prompt from:
3584    /// the commit hash, original message, diff_summary, and the partial
3585    /// amendment messages returned by each chunk.
3586    ///
3587    /// Verifies that the merge (3rd) request's user prompt contains all of:
3588    /// both partial messages, the original commit message, the diff_summary
3589    /// file paths, and the commit hash.
3590    #[tokio::test]
3591    async fn amendment_reduce_pass_prompt_content() {
3592        let dir = tempfile::tempdir().unwrap();
3593        let repo_view = make_large_diff_repo_view(&dir);
3594        let hash = "a".repeat(40);
3595
3596        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3597            Ok(valid_amendment_yaml(
3598                &hash,
3599                "feat(a): add module a implementation",
3600            )),
3601            Ok(valid_amendment_yaml(
3602                &hash,
3603                "feat(b): add module b implementation",
3604            )),
3605            Ok(valid_amendment_yaml(
3606                &hash,
3607                "feat(test): add modules a and b",
3608            )),
3609        ]);
3610
3611        let result = client
3612            .generate_amendments_with_options(&repo_view, false)
3613            .await;
3614
3615        assert!(result.is_ok());
3616
3617        let prompts = prompt_handle.prompts();
3618        assert_eq!(prompts.len(), 3);
3619
3620        // The merge pass is the last (3rd) request
3621        let (merge_system, merge_user) = &prompts[2];
3622
3623        // System prompt should be the amendment chunk merge prompt
3624        assert!(
3625            merge_system.contains("synthesiz"),
3626            "merge system prompt should contain synthesis instructions"
3627        );
3628
3629        // User prompt should contain the partial messages from chunks
3630        assert!(
3631            merge_user.contains("feat(a): add module a implementation"),
3632            "merge user prompt should contain chunk 1's partial message"
3633        );
3634        assert!(
3635            merge_user.contains("feat(b): add module b implementation"),
3636            "merge user prompt should contain chunk 2's partial message"
3637        );
3638
3639        // User prompt should contain the original commit message
3640        assert!(
3641            merge_user.contains("feat(test): large commit"),
3642            "merge user prompt should contain the original commit message"
3643        );
3644
3645        // User prompt should contain the diff_summary referencing both files
3646        assert!(
3647            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3648            "merge user prompt should contain the diff_summary"
3649        );
3650
3651        // User prompt should reference the commit hash
3652        assert!(
3653            merge_user.contains(&hash),
3654            "merge user prompt should reference the commit hash"
3655        );
3656    }
3657
3658    /// Two-chunk check split dispatch with issue deduplication and merge.
3659    ///
3660    /// Exercises `check_commit_split` which:
3661    /// 1. Dispatches 2 chunk requests (one per file)
3662    /// 2. Collects issues from both chunks into a `HashSet` keyed by
3663    ///    `(rule, severity, section)` — duplicates are dropped
3664    /// 3. Detects that both chunks have suggestions → calls
3665    ///    `merge_check_chunks` for the AI reduce pass
3666    ///
3667    /// Chunk 1 reports: `error:subject-too-long:Subject Line` +
3668    ///                   `warning:body-required:Content`
3669    /// Chunk 2 reports: `error:subject-too-long:Subject Line` (duplicate) +
3670    ///                   `info:scope-suggestion:Style` (new)
3671    ///
3672    /// Verifies: 3 unique issues after dedup, suggestion from merge pass,
3673    /// and the merge prompt contains both partial suggestions + diff_summary.
3674    #[tokio::test]
3675    async fn check_split_dedup_and_merge_prompt() {
3676        let dir = tempfile::tempdir().unwrap();
3677        let repo_view = make_large_diff_repo_view(&dir);
3678        let hash = "a".repeat(40);
3679
3680        // Chunk 1: error (subject-too-long) + warning (body-required) + suggestion
3681        let chunk1_yaml = format!(
3682            concat!(
3683                "checks:\n",
3684                "  - commit: \"{hash}\"\n",
3685                "    passes: false\n",
3686                "    issues:\n",
3687                "      - severity: error\n",
3688                "        section: \"Subject Line\"\n",
3689                "        rule: \"subject-too-long\"\n",
3690                "        explanation: \"Subject exceeds 72 characters\"\n",
3691                "      - severity: warning\n",
3692                "        section: \"Content\"\n",
3693                "        rule: \"body-required\"\n",
3694                "        explanation: \"Large change needs body\"\n",
3695                "    suggestion:\n",
3696                "      message: \"feat(a): shorter subject for a\"\n",
3697                "      explanation: \"Shortened subject for file a\"\n",
3698                "    summary: \"Adds module a\"\n",
3699            ),
3700            hash = hash,
3701        );
3702
3703        // Chunk 2: same error (different explanation) + new info issue + suggestion
3704        let chunk2_yaml = format!(
3705            concat!(
3706                "checks:\n",
3707                "  - commit: \"{hash}\"\n",
3708                "    passes: false\n",
3709                "    issues:\n",
3710                "      - severity: error\n",
3711                "        section: \"Subject Line\"\n",
3712                "        rule: \"subject-too-long\"\n",
3713                "        explanation: \"Subject line is way too long\"\n",
3714                "      - severity: info\n",
3715                "        section: \"Style\"\n",
3716                "        rule: \"scope-suggestion\"\n",
3717                "        explanation: \"Consider more specific scope\"\n",
3718                "    suggestion:\n",
3719                "      message: \"feat(b): shorter subject for b\"\n",
3720                "      explanation: \"Shortened subject for file b\"\n",
3721                "    summary: \"Adds module b\"\n",
3722            ),
3723            hash = hash,
3724        );
3725
3726        // Merge pass (called because suggestions exist)
3727        let merge_yaml = format!(
3728            concat!(
3729                "checks:\n",
3730                "  - commit: \"{hash}\"\n",
3731                "    passes: false\n",
3732                "    issues: []\n",
3733                "    suggestion:\n",
3734                "      message: \"feat(test): add modules a and b\"\n",
3735                "      explanation: \"Combined suggestion\"\n",
3736                "    summary: \"Adds modules a and b\"\n",
3737            ),
3738            hash = hash,
3739        );
3740
3741        let (client, response_handle, prompt_handle) =
3742            make_small_context_client_with_prompts(vec![
3743                Ok(chunk1_yaml),
3744                Ok(chunk2_yaml),
3745                Ok(merge_yaml),
3746            ]);
3747
3748        let result = client
3749            .check_commits_with_scopes(&repo_view, None, &[], true)
3750            .await;
3751
3752        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3753        let report = result.unwrap();
3754        assert_eq!(report.commits.len(), 1);
3755        assert!(!report.commits[0].passes);
3756        assert_eq!(response_handle.remaining(), 0);
3757
3758        // Dedup: 3 unique (rule, severity, section) tuples
3759        //  - subject-too-long / error / Subject Line   (appears in both → deduped)
3760        //  - body-required    / warning / Content
3761        //  - scope-suggestion / info / Style
3762        assert_eq!(
3763            report.commits[0].issues.len(),
3764            3,
3765            "expected 3 unique issues after dedup, got {:?}",
3766            report.commits[0]
3767                .issues
3768                .iter()
3769                .map(|i| &i.rule)
3770                .collect::<Vec<_>>()
3771        );
3772
3773        // Suggestion should come from the merge pass
3774        assert!(report.commits[0].suggestion.is_some());
3775        assert!(
3776            report.commits[0]
3777                .suggestion
3778                .as_ref()
3779                .unwrap()
3780                .message
3781                .contains("add modules a and b"),
3782            "suggestion should come from the merge pass"
3783        );
3784
3785        // Prompt content assertions
3786        let prompts = prompt_handle.prompts();
3787        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3788
3789        // Chunk prompts should collectively cover both files
3790        let (_, chunk1_user) = &prompts[0];
3791        let (_, chunk2_user) = &prompts[1];
3792        let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3793        assert!(
3794            combined_chunk_prompts.contains("src/a.rs")
3795                && combined_chunk_prompts.contains("src/b.rs"),
3796            "chunk prompts should collectively cover both files"
3797        );
3798
3799        // Merge pass prompt should contain partial suggestions
3800        let (merge_sys, merge_user) = &prompts[2];
3801        assert!(
3802            merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3803            "merge system prompt should be the check chunk merge prompt"
3804        );
3805        assert!(
3806            merge_user.contains("feat(a): shorter subject for a")
3807                && merge_user.contains("feat(b): shorter subject for b"),
3808            "merge user prompt should contain both partial suggestions"
3809        );
3810        // Merge prompt should contain the diff_summary
3811        assert!(
3812            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3813            "merge user prompt should contain the diff_summary"
3814        );
3815    }
3816
3817    // ── Amendment retry tests ──────────────────────────────────────────
3818
3819    #[tokio::test]
3820    async fn amendment_retry_parse_failure_then_success() {
3821        let dir = tempfile::tempdir().unwrap();
3822        let repo_view = make_test_repo_view(&dir);
3823        let hash = format!("{:0>40}", 0);
3824
3825        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3826            Ok("not valid yaml {{[".to_string()),
3827            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3828        ]);
3829
3830        let result = client
3831            .generate_amendments_with_options(&repo_view, false)
3832            .await;
3833
3834        assert!(
3835            result.is_ok(),
3836            "should succeed after retry: {:?}",
3837            result.err()
3838        );
3839        assert_eq!(result.unwrap().amendments.len(), 1);
3840        assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3841        assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3842    }
3843
3844    #[tokio::test]
3845    async fn amendment_retry_request_failure_then_success() {
3846        let dir = tempfile::tempdir().unwrap();
3847        let repo_view = make_test_repo_view(&dir);
3848        let hash = format!("{:0>40}", 0);
3849
3850        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3851            Err(anyhow::anyhow!("rate limit")),
3852            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3853        ]);
3854
3855        let result = client
3856            .generate_amendments_with_options(&repo_view, false)
3857            .await;
3858
3859        assert!(
3860            result.is_ok(),
3861            "should succeed after retry: {:?}",
3862            result.err()
3863        );
3864        assert_eq!(result.unwrap().amendments.len(), 1);
3865        assert_eq!(response_handle.remaining(), 0);
3866        assert_eq!(prompt_handle.request_count(), 2);
3867    }
3868
3869    #[tokio::test]
3870    async fn amendment_retry_all_attempts_exhausted() {
3871        let dir = tempfile::tempdir().unwrap();
3872        let repo_view = make_test_repo_view(&dir);
3873
3874        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3875            Ok("bad yaml 1".to_string()),
3876            Ok("bad yaml 2".to_string()),
3877            Ok("bad yaml 3".to_string()),
3878        ]);
3879
3880        let result = client
3881            .generate_amendments_with_options(&repo_view, false)
3882            .await;
3883
3884        assert!(result.is_err(), "should fail after all retries exhausted");
3885        assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3886        assert_eq!(
3887            prompt_handle.request_count(),
3888            3,
3889            "exactly 3 AI requests (1 + 2 retries)"
3890        );
3891    }
3892
3893    #[tokio::test]
3894    async fn amendment_retry_success_first_attempt() {
3895        let dir = tempfile::tempdir().unwrap();
3896        let repo_view = make_test_repo_view(&dir);
3897        let hash = format!("{:0>40}", 0);
3898
3899        let (client, response_handle, prompt_handle) =
3900            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3901                &hash,
3902                "feat(test): works first time",
3903            ))]);
3904
3905        let result = client
3906            .generate_amendments_with_options(&repo_view, false)
3907            .await;
3908
3909        assert!(result.is_ok());
3910        assert_eq!(response_handle.remaining(), 0);
3911        assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3912    }
3913
3914    #[tokio::test]
3915    async fn amendment_retry_mixed_request_and_parse_failures() {
3916        let dir = tempfile::tempdir().unwrap();
3917        let repo_view = make_test_repo_view(&dir);
3918        let hash = format!("{:0>40}", 0);
3919
3920        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3921            Err(anyhow::anyhow!("network error")),
3922            Ok("invalid yaml {{".to_string()),
3923            Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3924        ]);
3925
3926        let result = client
3927            .generate_amendments_with_options(&repo_view, false)
3928            .await;
3929
3930        assert!(
3931            result.is_ok(),
3932            "should succeed on third attempt: {:?}",
3933            result.err()
3934        );
3935        assert_eq!(result.unwrap().amendments.len(), 1);
3936        assert_eq!(response_handle.remaining(), 0);
3937        assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3938    }
3939
3940    // ── create_default_claude_client factory ───────────────────────
3941
3942    /// Serialises env-mutating factory tests in this module.
3943    static FACTORY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3944
3945    struct FactoryEnvGuard {
3946        _lock: std::sync::MutexGuard<'static, ()>,
3947        saved: Vec<(&'static str, Option<String>)>,
3948    }
3949
3950    impl FactoryEnvGuard {
3951        fn new(keys: &[&'static str]) -> Self {
3952            let lock = FACTORY_ENV_LOCK
3953                .lock()
3954                .unwrap_or_else(std::sync::PoisonError::into_inner);
3955            let saved = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
3956            for k in keys {
3957                std::env::remove_var(k);
3958            }
3959            Self { _lock: lock, saved }
3960        }
3961
3962        fn set(&self, key: &str, value: &str) {
3963            std::env::set_var(key, value);
3964        }
3965    }
3966
3967    impl Drop for FactoryEnvGuard {
3968        fn drop(&mut self) {
3969            for (k, v) in self.saved.drain(..) {
3970                match v {
3971                    Some(val) => std::env::set_var(k, val),
3972                    None => std::env::remove_var(k),
3973                }
3974            }
3975        }
3976    }
3977
3978    #[test]
3979    fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
3980        let guard = FactoryEnvGuard::new(&[
3981            "OMNI_DEV_AI_BACKEND",
3982            "USE_OPENAI",
3983            "USE_OLLAMA",
3984            "CLAUDE_CODE_USE_BEDROCK",
3985            "CLAUDE_MODEL",
3986            "CLAUDE_CODE_MODEL",
3987            "ANTHROPIC_MODEL",
3988        ]);
3989        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
3990
3991        let client = create_default_claude_client(None, None).expect("factory should succeed");
3992        let metadata = client.get_ai_client_metadata();
3993        assert_eq!(metadata.provider, "Claude CLI");
3994        // Default model falls through to the registry's claude default.
3995        assert_eq!(metadata.model, "claude-sonnet-4-6");
3996    }
3997
3998    #[test]
3999    fn factory_claude_cli_backend_honours_model_precedence() {
4000        let guard = FactoryEnvGuard::new(&[
4001            "OMNI_DEV_AI_BACKEND",
4002            "USE_OPENAI",
4003            "USE_OLLAMA",
4004            "CLAUDE_CODE_USE_BEDROCK",
4005            "CLAUDE_MODEL",
4006            "CLAUDE_CODE_MODEL",
4007            "ANTHROPIC_MODEL",
4008        ]);
4009        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4010        guard.set("CLAUDE_CODE_MODEL", "opus");
4011        // CLAUDE_MODEL has higher precedence than CLAUDE_CODE_MODEL.
4012        guard.set("CLAUDE_MODEL", "haiku");
4013
4014        let client = create_default_claude_client(None, None).expect("factory should succeed");
4015        let metadata = client.get_ai_client_metadata();
4016        assert_eq!(metadata.provider, "Claude CLI");
4017        assert_eq!(metadata.model, "haiku");
4018    }
4019
4020    #[test]
4021    fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4022        let guard = FactoryEnvGuard::new(&[
4023            "OMNI_DEV_AI_BACKEND",
4024            "USE_OPENAI",
4025            "USE_OLLAMA",
4026            "CLAUDE_CODE_USE_BEDROCK",
4027            "CLAUDE_MODEL",
4028            "CLAUDE_CODE_MODEL",
4029            "ANTHROPIC_MODEL",
4030        ]);
4031        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4032        guard.set("CLAUDE_MODEL", "haiku");
4033
4034        let client = create_default_claude_client(Some("opus".to_string()), None)
4035            .expect("factory should succeed");
4036        let metadata = client.get_ai_client_metadata();
4037        assert_eq!(metadata.model, "opus");
4038    }
4039
4040    #[test]
4041    fn factory_claude_cli_backend_accepts_underscore_alias() {
4042        let guard = FactoryEnvGuard::new(&[
4043            "OMNI_DEV_AI_BACKEND",
4044            "USE_OPENAI",
4045            "USE_OLLAMA",
4046            "CLAUDE_CODE_USE_BEDROCK",
4047            "CLAUDE_MODEL",
4048            "CLAUDE_CODE_MODEL",
4049            "ANTHROPIC_MODEL",
4050        ]);
4051        guard.set("OMNI_DEV_AI_BACKEND", "claude_cli");
4052
4053        let client = create_default_claude_client(None, None).expect("factory should succeed");
4054        let metadata = client.get_ai_client_metadata();
4055        assert_eq!(metadata.provider, "Claude CLI");
4056    }
4057}