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.
1613///
1614/// Async because the Ollama branch probes the local server for its
1615/// loaded context length so token-budget checks reflect what the server
1616/// actually loaded the model with (registry values are an estimate that
1617/// can exceed the live limit). All other branches finish synchronously.
1618pub async fn create_default_claude_client(
1619    model: Option<String>,
1620    beta_header: Option<(String, String)>,
1621) -> Result<ClaudeClient> {
1622    use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1623    use crate::claude::ai::openai::OpenAiAiClient;
1624    use crate::utils::settings::{get_env_var, get_env_vars};
1625
1626    // `claude -p` subprocess backend takes precedence when requested — it
1627    // reuses an existing Claude Code auth session and is the only backend
1628    // that accepts short model aliases (sonnet/opus/haiku), so it must
1629    // short-circuit before `validate_beta_header` runs below.
1630    let ai_backend = get_env_var("OMNI_DEV_AI_BACKEND").ok();
1631    let use_claude_cli = ai_backend
1632        .as_deref()
1633        .is_some_and(|v| matches!(v, "claude-cli" | "claude_cli"));
1634
1635    if use_claude_cli {
1636        if beta_header.is_some() {
1637            warn!(
1638                "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1639                 (the CLI's --betas flag has different semantics and is not forwarded)"
1640            );
1641        }
1642        let registry = crate::claude::model_config::get_model_registry();
1643        let cli_model = model
1644            .or_else(|| get_env_var("CLAUDE_MODEL").ok())
1645            .or_else(|| get_env_var("CLAUDE_CODE_MODEL").ok())
1646            .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1647            .unwrap_or_else(|| {
1648                registry
1649                    .get_default_model("claude")
1650                    .unwrap_or("claude-sonnet-4-6")
1651                    .to_string()
1652            });
1653        debug!(model = %cli_model, "Creating claude -p subprocess client");
1654        let ai_client = ClaudeCliAiClient::new(cli_model);
1655        return Ok(ClaudeClient::new(Box::new(ai_client)));
1656    }
1657
1658    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
1659    let use_openai = get_env_var("USE_OPENAI").is_ok_and(|val| val == "true");
1660
1661    let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1662
1663    // Check if we should use Bedrock
1664    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1665
1666    debug!(
1667        use_openai = use_openai,
1668        use_ollama = use_ollama,
1669        use_bedrock = use_bedrock,
1670        "Client selection flags"
1671    );
1672
1673    let registry = crate::claude::model_config::get_model_registry();
1674
1675    // Handle Ollama configuration
1676    if use_ollama {
1677        let ollama_model = model
1678            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1679            .unwrap_or_else(|| "llama2".to_string());
1680        validate_beta_header(&ollama_model, &beta_header)?;
1681        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1682        let mut ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1683        match ai_client.probe_loaded_context_length().await {
1684            Some(source) => {
1685                info!(
1686                    loaded_context_length = ai_client.loaded_context_length(),
1687                    source = source.as_str(),
1688                    model = %ai_client.get_metadata().model,
1689                    "Probed loaded context length from local server"
1690                );
1691            }
1692            None => {
1693                debug!(
1694                    "Loaded context length probe did not return a value; \
1695                     falling back to registry/default for token budget"
1696                );
1697            }
1698        }
1699        return Ok(ClaudeClient::new(Box::new(ai_client)));
1700    }
1701
1702    // Handle OpenAI configuration
1703    if use_openai {
1704        debug!("Creating OpenAI client");
1705        let openai_model = model
1706            .or_else(|| get_env_var("OPENAI_MODEL").ok())
1707            .unwrap_or_else(|| {
1708                registry
1709                    .get_default_model("openai")
1710                    .unwrap_or("gpt-5")
1711                    .to_string()
1712            });
1713        debug!(openai_model = %openai_model, "Selected OpenAI model");
1714        validate_beta_header(&openai_model, &beta_header)?;
1715
1716        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1717            debug!(error = ?e, "Failed to get OpenAI API key");
1718            ClaudeError::ApiKeyNotFound
1719        })?;
1720        debug!("OpenAI API key found");
1721
1722        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1723        debug!("OpenAI client created successfully");
1724        return Ok(ClaudeClient::new(Box::new(ai_client)));
1725    }
1726
1727    // For Claude clients, try to get model from env vars or use default
1728    let claude_model = model
1729        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1730        .unwrap_or_else(|| {
1731            registry
1732                .get_default_model("claude")
1733                .unwrap_or("claude-sonnet-4-6")
1734                .to_string()
1735        });
1736    validate_beta_header(&claude_model, &beta_header)?;
1737
1738    if use_bedrock {
1739        // Use Bedrock AI client
1740        let auth_token =
1741            get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1742
1743        let base_url =
1744            get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1745
1746        let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1747        return Ok(ClaudeClient::new(Box::new(ai_client)));
1748    }
1749
1750    // Default: use standard Claude AI client
1751    debug!("Falling back to Claude client");
1752    let api_key = get_env_vars(&[
1753        "CLAUDE_API_KEY",
1754        "ANTHROPIC_API_KEY",
1755        "ANTHROPIC_AUTH_TOKEN",
1756    ])
1757    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1758
1759    let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1760    debug!("Claude client created successfully");
1761    Ok(ClaudeClient::new(Box::new(ai_client)))
1762}
1763
1764#[cfg(test)]
1765#[allow(
1766    clippy::unwrap_used,
1767    clippy::expect_used,
1768    clippy::format_in_format_args
1769)]
1770mod tests {
1771    use super::*;
1772    use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1773    use std::future::Future;
1774    use std::pin::Pin;
1775    use std::sync::{Arc, Mutex};
1776
1777    /// Mock AI client for testing — never makes real HTTP requests.
1778    struct MockAiClient;
1779
1780    impl AiClient for MockAiClient {
1781        fn send_request<'a>(
1782            &'a self,
1783            _system_prompt: &'a str,
1784            _user_prompt: &'a str,
1785        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1786            Box::pin(async { Ok(String::new()) })
1787        }
1788
1789        fn get_metadata(&self) -> AiClientMetadata {
1790            AiClientMetadata {
1791                provider: "Mock".to_string(),
1792                model: "mock-model".to_string(),
1793                max_context_length: 200_000,
1794                max_response_length: 8_192,
1795                active_beta: None,
1796            }
1797        }
1798    }
1799
1800    fn make_client() -> ClaudeClient {
1801        ClaudeClient::new(Box::new(MockAiClient))
1802    }
1803
1804    /// Mock AI client that records both prompts and per-call options
1805    /// (the schema attached, if any). Used to verify
1806    /// [`ClaudeClient::send_with_optional_schema`] dispatches via the
1807    /// options-aware method when a schema is provided and via the plain
1808    /// method otherwise.
1809    ///
1810    /// Returns the configured `response` string from both `send_request`
1811    /// and `send_request_with_options` so tests that need a parseable
1812    /// response (e.g. the refine_* coherence paths) can plug in canned
1813    /// YAML/JSON.
1814    struct SchemaRecordingMockAiClient {
1815        capabilities: AiClientCapabilities,
1816        response: String,
1817        recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1818        recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1819    }
1820    impl SchemaRecordingMockAiClient {
1821        fn new(supports_response_schema: bool) -> Self {
1822            Self::with_response(supports_response_schema, String::new())
1823        }
1824
1825        fn with_response(supports_response_schema: bool, response: String) -> Self {
1826            Self {
1827                capabilities: AiClientCapabilities {
1828                    supports_response_schema,
1829                },
1830                response,
1831                recorded_options: Arc::new(Mutex::new(Vec::new())),
1832                recorded_plain: Arc::new(Mutex::new(Vec::new())),
1833            }
1834        }
1835    }
1836
1837    impl AiClient for SchemaRecordingMockAiClient {
1838        fn send_request<'a>(
1839            &'a self,
1840            system_prompt: &'a str,
1841            user_prompt: &'a str,
1842        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1843            let plain = self.recorded_plain.clone();
1844            let sys = system_prompt.to_string();
1845            let usr = user_prompt.to_string();
1846            let response = self.response.clone();
1847            Box::pin(async move {
1848                plain.lock().unwrap().push((sys, usr));
1849                Ok(response)
1850            })
1851        }
1852
1853        fn capabilities(&self) -> AiClientCapabilities {
1854            self.capabilities
1855        }
1856
1857        fn send_request_with_options<'a>(
1858            &'a self,
1859            _system_prompt: &'a str,
1860            _user_prompt: &'a str,
1861            options: RequestOptions,
1862        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1863            let recorded = self.recorded_options.clone();
1864            let response = self.response.clone();
1865            Box::pin(async move {
1866                recorded.lock().unwrap().push(options);
1867                Ok(response)
1868            })
1869        }
1870
1871        fn get_metadata(&self) -> AiClientMetadata {
1872            AiClientMetadata {
1873                provider: "SchemaMock".to_string(),
1874                model: "schema-mock".to_string(),
1875                max_context_length: 200_000,
1876                max_response_length: 8_192,
1877                active_beta: None,
1878            }
1879        }
1880    }
1881
1882    // ── ClaudeClient schema-routing helpers ───────────────────────────
1883
1884    /// Backends that don't advertise schema support take the
1885    /// `send_request` branch in `send_with_optional_schema` regardless
1886    /// of whether a schema was supplied at the call site.
1887    #[tokio::test]
1888    async fn send_with_optional_schema_without_caps_uses_plain_send() {
1889        let inner = SchemaRecordingMockAiClient::new(false);
1890        let plain_log = inner.recorded_plain.clone();
1891        let opts_log = inner.recorded_options.clone();
1892        let client = ClaudeClient::new(Box::new(inner));
1893
1894        let schema = serde_json::json!({"type": "object"});
1895        client
1896            .send_with_optional_schema(
1897                "sys",
1898                "usr",
1899                client.schema_if_supported(&schema), // → None
1900            )
1901            .await
1902            .unwrap();
1903
1904        assert_eq!(plain_log.lock().unwrap().len(), 1);
1905        assert!(opts_log.lock().unwrap().is_empty());
1906    }
1907
1908    /// Backends that advertise schema support take the
1909    /// `send_request_with_options` branch and receive the schema in the
1910    /// options struct.
1911    #[tokio::test]
1912    async fn send_with_optional_schema_with_caps_uses_options_send() {
1913        let inner = SchemaRecordingMockAiClient::new(true);
1914        let plain_log = inner.recorded_plain.clone();
1915        let opts_log = inner.recorded_options.clone();
1916        let client = ClaudeClient::new(Box::new(inner));
1917
1918        let schema = serde_json::json!({"type": "object", "additionalProperties": false});
1919        client
1920            .send_with_optional_schema(
1921                "sys",
1922                "usr",
1923                client.schema_if_supported(&schema), // → Some
1924            )
1925            .await
1926            .unwrap();
1927
1928        let recorded = opts_log.lock().unwrap();
1929        assert_eq!(recorded.len(), 1);
1930        assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
1931        assert!(plain_log.lock().unwrap().is_empty());
1932    }
1933
1934    /// `adjusted_system_prompt` only appends the JSON-schema override
1935    /// suffix when the active backend supports schema enforcement.
1936    #[test]
1937    fn adjusted_system_prompt_adds_suffix_when_supported() {
1938        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1939        let result = client.adjusted_system_prompt("body".to_string());
1940        assert!(result.starts_with("body"));
1941        assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
1942    }
1943
1944    #[test]
1945    fn adjusted_system_prompt_passes_through_when_not_supported() {
1946        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1947        let result = client.adjusted_system_prompt("body".to_string());
1948        assert_eq!(result, "body");
1949    }
1950
1951    #[test]
1952    fn schema_if_supported_returns_some_when_supported() {
1953        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1954        let schema = serde_json::json!({"type": "object"});
1955        let returned = client.schema_if_supported(&schema);
1956        assert!(returned.is_some());
1957        assert!(std::ptr::eq(
1958            std::ptr::from_ref(returned.unwrap()),
1959            std::ptr::addr_of!(schema)
1960        ));
1961    }
1962
1963    #[test]
1964    fn schema_if_supported_returns_none_when_not_supported() {
1965        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1966        let schema = serde_json::json!({"type": "object"});
1967        assert!(client.schema_if_supported(&schema).is_none());
1968    }
1969
1970    // ── refine_amendments_coherence / refine_checks_coherence ────────
1971
1972    /// Exercises the full body of `refine_amendments_coherence`:
1973    /// adjusted_system_prompt → validate_prompt_budget → schema-aware
1974    /// dispatch → parse_amendment_response. Uses a schema-supporting
1975    /// mock so the schema attachment branch is taken too.
1976    #[tokio::test]
1977    async fn refine_amendments_coherence_round_trip() {
1978        let mock = SchemaRecordingMockAiClient::with_response(
1979            true, // supports_response_schema
1980            "amendments: []".to_string(),
1981        );
1982        let recorded_opts = mock.recorded_options.clone();
1983        let client = ClaudeClient::new(Box::new(mock));
1984
1985        let amendment = crate::data::amendments::Amendment {
1986            commit: "abc123".to_string(),
1987            message: "feat: do thing".to_string(),
1988            summary: "did the thing".to_string(),
1989        };
1990        let items = vec![(amendment, "summary text".to_string())];
1991
1992        let result = client
1993            .refine_amendments_coherence(&items)
1994            .await
1995            .expect("coherence refinement should succeed");
1996        assert!(result.amendments.is_empty());
1997
1998        // Verify the schema-aware dispatch path was taken and that the
1999        // attached schema is the AmendmentFile schema.
2000        let recorded = recorded_opts.lock().unwrap();
2001        assert_eq!(recorded.len(), 1);
2002        let attached = recorded[0]
2003            .response_schema
2004            .as_ref()
2005            .expect("schema must be attached when capability is true");
2006        assert_eq!(
2007            attached,
2008            response_schema::amendment_file_schema(),
2009            "refine_amendments_coherence should attach the AmendmentFile schema"
2010        );
2011    }
2012
2013    /// Same coverage shape as the amendment variant, but for the check
2014    /// coherence path. Uses `parse_check_response` which needs a
2015    /// repository view to map commit hashes back to messages — we
2016    /// supply an empty view.
2017    #[tokio::test]
2018    async fn refine_checks_coherence_round_trip() {
2019        let mock = SchemaRecordingMockAiClient::with_response(
2020            true, // supports_response_schema
2021            "checks: []".to_string(),
2022        );
2023        let recorded_opts = mock.recorded_options.clone();
2024        let client = ClaudeClient::new(Box::new(mock));
2025
2026        let check = crate::data::check::CommitCheckResult {
2027            hash: "abc123".to_string(),
2028            message: "feat: do thing".to_string(),
2029            issues: Vec::new(),
2030            suggestion: None,
2031            passes: true,
2032            summary: Some("summary".to_string()),
2033        };
2034        let items = vec![(check, "summary text".to_string())];
2035        let dir = tempfile::TempDir::new().unwrap();
2036        let repo_view = make_test_repo_view(&dir);
2037
2038        let result = client
2039            .refine_checks_coherence(&items, &repo_view)
2040            .await
2041            .expect("coherence refinement should succeed");
2042        assert_eq!(result.summary.total_commits, 0);
2043
2044        let recorded = recorded_opts.lock().unwrap();
2045        assert_eq!(recorded.len(), 1);
2046        let attached = recorded[0]
2047            .response_schema
2048            .as_ref()
2049            .expect("schema must be attached when capability is true");
2050        assert_eq!(
2051            attached,
2052            response_schema::check_response_schema(),
2053            "refine_checks_coherence should attach the AiCheckResponse schema"
2054        );
2055    }
2056
2057    /// Verifies the no-schema branch of refine_amendments_coherence —
2058    /// when the backend doesn't advertise schema support, dispatch
2059    /// falls through to plain `send_request` and no schema is attached.
2060    #[tokio::test]
2061    async fn refine_amendments_coherence_without_schema_capability() {
2062        let mock = SchemaRecordingMockAiClient::with_response(
2063            false, // supports_response_schema
2064            "amendments: []".to_string(),
2065        );
2066        let recorded_plain = mock.recorded_plain.clone();
2067        let recorded_opts = mock.recorded_options.clone();
2068        let client = ClaudeClient::new(Box::new(mock));
2069
2070        let amendment = crate::data::amendments::Amendment {
2071            commit: "abc123".to_string(),
2072            message: "feat: do thing".to_string(),
2073            summary: String::new(),
2074        };
2075        let items = vec![(amendment, "summary".to_string())];
2076
2077        client
2078            .refine_amendments_coherence(&items)
2079            .await
2080            .expect("coherence refinement should succeed without schema support");
2081
2082        assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2083        assert!(
2084            recorded_opts.lock().unwrap().is_empty(),
2085            "no-schema backend must not be reached via the options path"
2086        );
2087    }
2088
2089    // ── extract_yaml_from_response ─────────────────────────────────
2090
2091    #[test]
2092    fn extract_yaml_pure_amendments() {
2093        let client = make_client();
2094        let content = "amendments:\n  - commit: abc123\n    message: test";
2095        let result = client.extract_yaml_from_response(content);
2096        assert!(result.starts_with("amendments:"));
2097    }
2098
2099    #[test]
2100    fn extract_yaml_with_markdown_yaml_block() {
2101        let client = make_client();
2102        let content = "Here is the result:\n```yaml\namendments:\n  - commit: abc\n```\n";
2103        let result = client.extract_yaml_from_response(content);
2104        assert!(result.starts_with("amendments:"));
2105    }
2106
2107    #[test]
2108    fn extract_yaml_with_generic_code_block() {
2109        let client = make_client();
2110        let content = "```\namendments:\n  - commit: abc\n```";
2111        let result = client.extract_yaml_from_response(content);
2112        assert!(result.starts_with("amendments:"));
2113    }
2114
2115    #[test]
2116    fn extract_yaml_with_whitespace() {
2117        let client = make_client();
2118        let content = "  \n  amendments:\n  - commit: abc\n  ";
2119        let result = client.extract_yaml_from_response(content);
2120        assert!(result.starts_with("amendments:"));
2121    }
2122
2123    #[test]
2124    fn extract_yaml_fallback_returns_trimmed() {
2125        let client = make_client();
2126        let content = "  some random text  ";
2127        let result = client.extract_yaml_from_response(content);
2128        assert_eq!(result, "some random text");
2129    }
2130
2131    // ── extract_yaml_from_check_response ───────────────────────────
2132
2133    #[test]
2134    fn extract_check_yaml_pure() {
2135        let client = make_client();
2136        let content = "checks:\n  - commit: abc123";
2137        let result = client.extract_yaml_from_check_response(content);
2138        assert!(result.starts_with("checks:"));
2139    }
2140
2141    #[test]
2142    fn extract_check_yaml_markdown_block() {
2143        let client = make_client();
2144        let content = "```yaml\nchecks:\n  - commit: abc\n```";
2145        let result = client.extract_yaml_from_check_response(content);
2146        assert!(result.starts_with("checks:"));
2147    }
2148
2149    #[test]
2150    fn extract_check_yaml_generic_block() {
2151        let client = make_client();
2152        let content = "```\nchecks:\n  - commit: abc\n```";
2153        let result = client.extract_yaml_from_check_response(content);
2154        assert!(result.starts_with("checks:"));
2155    }
2156
2157    #[test]
2158    fn extract_check_yaml_fallback() {
2159        let client = make_client();
2160        let content = "  unexpected content  ";
2161        let result = client.extract_yaml_from_check_response(content);
2162        assert_eq!(result, "unexpected content");
2163    }
2164
2165    // ── parse_amendment_response ────────────────────────────────────
2166
2167    #[test]
2168    fn parse_amendment_response_valid() {
2169        let client = make_client();
2170        let yaml = format!(
2171            "amendments:\n  - commit: \"{}\"\n    message: \"test message\"",
2172            "a".repeat(40)
2173        );
2174        let result = client.parse_amendment_response(&yaml);
2175        assert!(result.is_ok());
2176        assert_eq!(result.unwrap().amendments.len(), 1);
2177    }
2178
2179    #[test]
2180    fn parse_amendment_response_invalid_yaml() {
2181        let client = make_client();
2182        let result = client.parse_amendment_response("not: valid: yaml: [{{");
2183        assert!(result.is_err());
2184    }
2185
2186    #[test]
2187    fn parse_amendment_response_invalid_hash() {
2188        let client = make_client();
2189        let yaml = "amendments:\n  - commit: \"short\"\n    message: \"test\"";
2190        let result = client.parse_amendment_response(yaml);
2191        assert!(result.is_err());
2192    }
2193
2194    // ── validate_beta_header ───────────────────────────────────────
2195
2196    #[test]
2197    fn validate_beta_header_none_passes() {
2198        let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2199        assert!(result.is_ok());
2200    }
2201
2202    #[test]
2203    fn validate_beta_header_unsupported_fails() {
2204        let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2205        let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2206        assert!(result.is_err());
2207    }
2208
2209    // ── ClaudeClient::new / get_ai_client_metadata ─────────────────
2210
2211    #[test]
2212    fn client_metadata() {
2213        let client = make_client();
2214        let metadata = client.get_ai_client_metadata();
2215        assert_eq!(metadata.provider, "Mock");
2216        assert_eq!(metadata.model, "mock-model");
2217    }
2218
2219    // ── property tests ────────────────────────────────────────────
2220
2221    mod prop {
2222        use super::*;
2223        use proptest::prelude::*;
2224
2225        proptest! {
2226            #[test]
2227            fn yaml_response_output_trimmed(s in ".*") {
2228                let client = make_client();
2229                let result = client.extract_yaml_from_response(&s);
2230                prop_assert_eq!(&result, result.trim());
2231            }
2232
2233            #[test]
2234            fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2235                let client = make_client();
2236                let input = format!("amendments:{tail}");
2237                let result = client.extract_yaml_from_response(&input);
2238                prop_assert!(result.starts_with("amendments:"));
2239            }
2240
2241            #[test]
2242            fn check_response_checks_prefix_preserved(tail in ".*") {
2243                let client = make_client();
2244                let input = format!("checks:{tail}");
2245                let result = client.extract_yaml_from_check_response(&input);
2246                prop_assert!(result.starts_with("checks:"));
2247            }
2248
2249            #[test]
2250            fn yaml_fenced_block_strips_fences(
2251                content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2252            ) {
2253                let client = make_client();
2254                let input = format!("```yaml\n{content}\n```");
2255                let result = client.extract_yaml_from_response(&input);
2256                prop_assert!(!result.contains("```"));
2257            }
2258        }
2259    }
2260
2261    // ── ConfigurableMockAiClient tests ──────────────────────────────
2262
2263    fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2264        ClaudeClient::new(Box::new(
2265            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2266        ))
2267    }
2268
2269    fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2270        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2271        use crate::git::commit::FileChanges;
2272        use crate::git::{CommitAnalysis, CommitInfo};
2273
2274        let diff_path = dir.path().join("0.diff");
2275        std::fs::write(&diff_path, "+added line\n").unwrap();
2276
2277        crate::data::RepositoryView {
2278            versions: None,
2279            explanation: FieldExplanation::default(),
2280            working_directory: WorkingDirectoryInfo {
2281                clean: true,
2282                untracked_changes: Vec::new(),
2283            },
2284            remotes: Vec::new(),
2285            ai: AiInfo {
2286                scratch: String::new(),
2287            },
2288            branch_info: None,
2289            pr_template: None,
2290            pr_template_location: None,
2291            branch_prs: None,
2292            commits: vec![CommitInfo {
2293                hash: format!("{:0>40}", 0),
2294                author: "Test <test@test.com>".to_string(),
2295                date: chrono::Utc::now().fixed_offset(),
2296                original_message: "feat(test): add something".to_string(),
2297                in_main_branches: Vec::new(),
2298                analysis: CommitAnalysis {
2299                    detected_type: "feat".to_string(),
2300                    detected_scope: "test".to_string(),
2301                    proposed_message: "feat(test): add something".to_string(),
2302                    file_changes: FileChanges {
2303                        total_files: 1,
2304                        files_added: 1,
2305                        files_deleted: 0,
2306                        file_list: Vec::new(),
2307                    },
2308                    diff_summary: "file.rs | 1 +".to_string(),
2309                    diff_file: diff_path.to_string_lossy().to_string(),
2310                    file_diffs: Vec::new(),
2311                },
2312            }],
2313        }
2314    }
2315
2316    fn valid_check_yaml() -> String {
2317        format!(
2318            "checks:\n  - commit: \"{hash}\"\n    passes: true\n    issues: []\n",
2319            hash = format!("{:0>40}", 0)
2320        )
2321    }
2322
2323    #[tokio::test]
2324    async fn send_message_propagates_ai_error() {
2325        let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2326        let result = client.send_message("sys", "usr").await;
2327        assert!(result.is_err());
2328        assert!(result.unwrap_err().to_string().contains("mock error"));
2329    }
2330
2331    #[tokio::test]
2332    async fn check_commits_succeeds_after_request_error() {
2333        let dir = tempfile::tempdir().unwrap();
2334        let repo_view = make_test_repo_view(&dir);
2335        // First attempt: request error; retries return valid response.
2336        let client = make_configurable_client(vec![
2337            Err(anyhow::anyhow!("rate limit")),
2338            Ok(valid_check_yaml()),
2339            Ok(valid_check_yaml()),
2340        ]);
2341        let result = client
2342            .check_commits_with_scopes(&repo_view, None, &[], false)
2343            .await;
2344        assert!(result.is_ok());
2345    }
2346
2347    #[tokio::test]
2348    async fn check_commits_succeeds_after_parse_error() {
2349        let dir = tempfile::tempdir().unwrap();
2350        let repo_view = make_test_repo_view(&dir);
2351        // First attempt: AI returns malformed YAML; retry succeeds.
2352        let client = make_configurable_client(vec![
2353            Ok("not: valid: yaml: [[".to_string()),
2354            Ok(valid_check_yaml()),
2355            Ok(valid_check_yaml()),
2356        ]);
2357        let result = client
2358            .check_commits_with_scopes(&repo_view, None, &[], false)
2359            .await;
2360        assert!(result.is_ok());
2361    }
2362
2363    #[tokio::test]
2364    async fn check_commits_fails_after_all_retries_exhausted() {
2365        let dir = tempfile::tempdir().unwrap();
2366        let repo_view = make_test_repo_view(&dir);
2367        let client = make_configurable_client(vec![
2368            Err(anyhow::anyhow!("first failure")),
2369            Err(anyhow::anyhow!("second failure")),
2370            Err(anyhow::anyhow!("final failure")),
2371        ]);
2372        let result = client
2373            .check_commits_with_scopes(&repo_view, None, &[], false)
2374            .await;
2375        assert!(result.is_err());
2376    }
2377
2378    #[tokio::test]
2379    async fn check_commits_fails_when_all_parses_fail() {
2380        let dir = tempfile::tempdir().unwrap();
2381        let repo_view = make_test_repo_view(&dir);
2382        let client = make_configurable_client(vec![
2383            Ok("bad yaml [[".to_string()),
2384            Ok("bad yaml [[".to_string()),
2385            Ok("bad yaml [[".to_string()),
2386        ]);
2387        let result = client
2388            .check_commits_with_scopes(&repo_view, None, &[], false)
2389            .await;
2390        assert!(result.is_err());
2391    }
2392
2393    // ── split dispatch tests ─────────────────────────────────────
2394
2395    /// Creates a mock client with a constrained context window.
2396    ///
2397    /// The window is large enough that a single-file chunk fits, but too
2398    /// small for both files together (including system prompt overhead).
2399    fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2400        // Context of 50k with more conservative token estimation (2.5 chars/token
2401        // vs 3.5) ensures per-file diffs fit in chunks without placeholders while
2402        // still being large enough to trigger split dispatch for multiple files.
2403        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2404            .with_context_length(50_000);
2405        ClaudeClient::new(Box::new(mock))
2406    }
2407
2408    /// Like [`make_small_context_client`] but also returns a handle to inspect
2409    /// how many mock responses remain unconsumed after the test runs.
2410    fn make_small_context_client_tracked(
2411        responses: Vec<Result<String>>,
2412    ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2413        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2414            .with_context_length(50_000);
2415        let handle = mock.response_handle();
2416        (ClaudeClient::new(Box::new(mock)), handle)
2417    }
2418
2419    /// Creates a repo view with per-file diffs large enough to exceed the
2420    /// constrained context window, ensuring the split dispatch path triggers.
2421    fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2422        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2423        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2424        use crate::git::{CommitAnalysis, CommitInfo};
2425
2426        let hash = "a".repeat(40);
2427
2428        // Write a full (flat) diff file large enough to bust the budget.
2429        // With 50k context / 2.5 chars-per-token / 1.2 margin, available ≈ 41k tokens.
2430        // 120k chars → ~57,600 tokens → well over budget.
2431        let full_diff = "x".repeat(120_000);
2432        let flat_diff_path = dir.path().join("full.diff");
2433        std::fs::write(&flat_diff_path, &full_diff).unwrap();
2434
2435        // Write two large per-file diff files (~30K chars each ≈ 14,400 tokens with
2436        // conservative 2.5 chars/token * 1.2 margin estimation)
2437        let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2438        let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2439
2440        let path_a = dir.path().join("0000.diff");
2441        let path_b = dir.path().join("0001.diff");
2442        std::fs::write(&path_a, &diff_a).unwrap();
2443        std::fs::write(&path_b, &diff_b).unwrap();
2444
2445        crate::data::RepositoryView {
2446            versions: None,
2447            explanation: FieldExplanation::default(),
2448            working_directory: WorkingDirectoryInfo {
2449                clean: true,
2450                untracked_changes: Vec::new(),
2451            },
2452            remotes: Vec::new(),
2453            ai: AiInfo {
2454                scratch: String::new(),
2455            },
2456            branch_info: None,
2457            pr_template: None,
2458            pr_template_location: None,
2459            branch_prs: None,
2460            commits: vec![CommitInfo {
2461                hash,
2462                author: "Test <test@test.com>".to_string(),
2463                date: chrono::Utc::now().fixed_offset(),
2464                original_message: "feat(test): large commit".to_string(),
2465                in_main_branches: Vec::new(),
2466                analysis: CommitAnalysis {
2467                    detected_type: "feat".to_string(),
2468                    detected_scope: "test".to_string(),
2469                    proposed_message: "feat(test): large commit".to_string(),
2470                    file_changes: FileChanges {
2471                        total_files: 2,
2472                        files_added: 2,
2473                        files_deleted: 0,
2474                        file_list: vec![
2475                            FileChange {
2476                                status: "A".to_string(),
2477                                file: "src/a.rs".to_string(),
2478                            },
2479                            FileChange {
2480                                status: "A".to_string(),
2481                                file: "src/b.rs".to_string(),
2482                            },
2483                        ],
2484                    },
2485                    diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2486                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2487                    file_diffs: vec![
2488                        FileDiffRef {
2489                            path: "src/a.rs".to_string(),
2490                            diff_file: path_a.to_string_lossy().to_string(),
2491                            byte_len: diff_a.len(),
2492                        },
2493                        FileDiffRef {
2494                            path: "src/b.rs".to_string(),
2495                            diff_file: path_b.to_string_lossy().to_string(),
2496                            byte_len: diff_b.len(),
2497                        },
2498                    ],
2499                },
2500            }],
2501        }
2502    }
2503
2504    fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2505        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"{message}\"")
2506    }
2507
2508    #[tokio::test]
2509    async fn generate_amendments_split_dispatch() {
2510        let dir = tempfile::tempdir().unwrap();
2511        let repo_view = make_large_diff_repo_view(&dir);
2512        let hash = "a".repeat(40);
2513
2514        // Responses: chunk 1 + chunk 2 + merge pass
2515        let client = make_small_context_client(vec![
2516            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2517            Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2518            Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2519        ]);
2520
2521        let result = client
2522            .generate_amendments_with_options(&repo_view, false)
2523            .await;
2524
2525        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2526        let amendments = result.unwrap();
2527        assert_eq!(amendments.amendments.len(), 1);
2528        assert_eq!(amendments.amendments[0].commit, hash);
2529        assert!(amendments.amendments[0]
2530            .message
2531            .contains("add a.rs and b.rs"));
2532    }
2533
2534    #[tokio::test]
2535    async fn generate_amendments_split_chunk_failure() {
2536        let dir = tempfile::tempdir().unwrap();
2537        let repo_view = make_large_diff_repo_view(&dir);
2538        let hash = "a".repeat(40);
2539
2540        // First chunk succeeds, second chunk fails
2541        let client = make_small_context_client(vec![
2542            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2543            Err(anyhow::anyhow!("rate limit exceeded")),
2544        ]);
2545
2546        let result = client
2547            .generate_amendments_with_options(&repo_view, false)
2548            .await;
2549
2550        assert!(result.is_err());
2551    }
2552
2553    #[tokio::test]
2554    async fn generate_amendments_no_split_when_fits() {
2555        let dir = tempfile::tempdir().unwrap();
2556        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2557        let hash = format!("{:0>40}", 0);
2558
2559        // Only one response needed — no split dispatch
2560        let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2561            &hash,
2562            "feat(test): improved message",
2563        ))]);
2564
2565        let result = client
2566            .generate_amendments_with_options(&repo_view, false)
2567            .await;
2568
2569        assert!(result.is_ok());
2570        assert_eq!(result.unwrap().amendments.len(), 1);
2571    }
2572
2573    // ── check split dispatch tests ──────────────────────────────
2574
2575    fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2576        format!(
2577            "checks:\n  - commit: \"{hash}\"\n    passes: {passes}\n    issues: []\n    summary: \"test summary\"\n"
2578        )
2579    }
2580
2581    fn valid_check_yaml_with_issues(hash: &str) -> String {
2582        format!(
2583            concat!(
2584                "checks:\n",
2585                "  - commit: \"{hash}\"\n",
2586                "    passes: false\n",
2587                "    issues:\n",
2588                "      - severity: error\n",
2589                "        section: \"Subject Line\"\n",
2590                "        rule: \"imperative-mood\"\n",
2591                "        explanation: \"Subject uses past tense\"\n",
2592                "    suggestion:\n",
2593                "      message: \"feat(test): shorter subject\"\n",
2594                "      explanation: \"Shortened subject line\"\n",
2595                "    summary: \"Large commit with issues\"\n",
2596            ),
2597            hash = hash,
2598        )
2599    }
2600
2601    fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2602        format!(
2603            concat!(
2604                "checks:\n",
2605                "  - commit: \"{hash}\"\n",
2606                "    passes: true\n",
2607                "    issues: []\n",
2608                "    summary: \"chunk summary\"\n",
2609            ),
2610            hash = hash,
2611        )
2612    }
2613
2614    #[tokio::test]
2615    async fn check_commits_split_dispatch() {
2616        let dir = tempfile::tempdir().unwrap();
2617        let repo_view = make_large_diff_repo_view(&dir);
2618        let hash = "a".repeat(40);
2619
2620        // Responses: chunk 1 (issues + suggestion) + chunk 2 (issues + suggestion) + merge pass
2621        let client = make_small_context_client(vec![
2622            Ok(valid_check_yaml_with_issues(&hash)),
2623            Ok(valid_check_yaml_with_issues(&hash)),
2624            Ok(valid_check_yaml_with_issues(&hash)), // merge pass response
2625        ]);
2626
2627        let result = client
2628            .check_commits_with_scopes(&repo_view, None, &[], true)
2629            .await;
2630
2631        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2632        let report = result.unwrap();
2633        assert_eq!(report.commits.len(), 1);
2634        assert!(!report.commits[0].passes);
2635        // Dedup: both chunks report the same (rule, severity, section), so only 1 unique issue
2636        assert_eq!(report.commits[0].issues.len(), 1);
2637        assert_eq!(report.commits[0].issues[0].rule, "imperative-mood");
2638    }
2639
2640    #[tokio::test]
2641    async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2642        let dir = tempfile::tempdir().unwrap();
2643        let repo_view = make_large_diff_repo_view(&dir);
2644        let hash = "a".repeat(40);
2645
2646        // Responses: chunk 1 + chunk 2, both passing with no suggestions
2647        // No merge pass needed — only 2 responses
2648        let client = make_small_context_client(vec![
2649            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2650            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2651        ]);
2652
2653        let result = client
2654            .check_commits_with_scopes(&repo_view, None, &[], false)
2655            .await;
2656
2657        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2658        let report = result.unwrap();
2659        assert_eq!(report.commits.len(), 1);
2660        assert!(report.commits[0].passes);
2661        assert!(report.commits[0].issues.is_empty());
2662        assert!(report.commits[0].suggestion.is_none());
2663        // First non-None summary from chunks
2664        assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2665    }
2666
2667    #[tokio::test]
2668    async fn check_commits_split_chunk_failure() {
2669        let dir = tempfile::tempdir().unwrap();
2670        let repo_view = make_large_diff_repo_view(&dir);
2671        let hash = "a".repeat(40);
2672
2673        // First chunk succeeds, second chunk fails
2674        let client = make_small_context_client(vec![
2675            Ok(valid_check_yaml_for(&hash, true)),
2676            Err(anyhow::anyhow!("rate limit exceeded")),
2677        ]);
2678
2679        let result = client
2680            .check_commits_with_scopes(&repo_view, None, &[], false)
2681            .await;
2682
2683        assert!(result.is_err());
2684    }
2685
2686    #[tokio::test]
2687    async fn check_commits_no_split_when_fits() {
2688        let dir = tempfile::tempdir().unwrap();
2689        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2690        let hash = format!("{:0>40}", 0);
2691
2692        // Only one response needed — no split dispatch
2693        let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2694
2695        let result = client
2696            .check_commits_with_scopes(&repo_view, None, &[], false)
2697            .await;
2698
2699        assert!(result.is_ok());
2700        assert_eq!(result.unwrap().commits.len(), 1);
2701    }
2702
2703    #[tokio::test]
2704    async fn check_commits_split_dedup_across_chunks() {
2705        let dir = tempfile::tempdir().unwrap();
2706        let repo_view = make_large_diff_repo_view(&dir);
2707        let hash = "a".repeat(40);
2708
2709        // Chunk 1: two issues (error + warning)
2710        let chunk1 = format!(
2711            concat!(
2712                "checks:\n",
2713                "  - commit: \"{hash}\"\n",
2714                "    passes: false\n",
2715                "    issues:\n",
2716                "      - severity: error\n",
2717                "        section: \"Subject Line\"\n",
2718                "        rule: \"imperative-mood\"\n",
2719                "        explanation: \"Subject uses past tense\"\n",
2720                "      - severity: warning\n",
2721                "        section: \"Content\"\n",
2722                "        rule: \"body-required\"\n",
2723                "        explanation: \"Large change needs body\"\n",
2724            ),
2725            hash = hash,
2726        );
2727
2728        // Chunk 2: same error (different wording) + new info issue
2729        let chunk2 = format!(
2730            concat!(
2731                "checks:\n",
2732                "  - commit: \"{hash}\"\n",
2733                "    passes: false\n",
2734                "    issues:\n",
2735                "      - severity: error\n",
2736                "        section: \"Subject Line\"\n",
2737                "        rule: \"imperative-mood\"\n",
2738                "        explanation: \"Subject line is too long\"\n",
2739                "      - severity: info\n",
2740                "        section: \"Style\"\n",
2741                "        rule: \"scope-suggestion\"\n",
2742                "        explanation: \"Consider more specific scope\"\n",
2743            ),
2744            hash = hash,
2745        );
2746
2747        // No suggestions → no merge pass needed
2748        let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
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_eq!(report.commits.len(), 1);
2757        assert!(!report.commits[0].passes);
2758        // 3 unique issues: imperative-mood, body-required, scope-suggestion
2759        // (imperative-mood appears in both chunks but deduped)
2760        assert_eq!(report.commits[0].issues.len(), 3);
2761    }
2762
2763    #[tokio::test]
2764    async fn check_commits_split_passes_only_when_all_chunks_pass() {
2765        let dir = tempfile::tempdir().unwrap();
2766        let repo_view = make_large_diff_repo_view(&dir);
2767        let hash = "a".repeat(40);
2768
2769        // Chunk 1 passes, chunk 2 fails
2770        let client = make_small_context_client(vec![
2771            Ok(valid_check_yaml_for(&hash, true)),
2772            Ok(valid_check_yaml_for(&hash, false)),
2773        ]);
2774
2775        let result = client
2776            .check_commits_with_scopes(&repo_view, None, &[], false)
2777            .await;
2778
2779        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2780        let report = result.unwrap();
2781        assert!(
2782            !report.commits[0].passes,
2783            "should fail when any chunk fails"
2784        );
2785    }
2786
2787    // ── multi-commit and PR generation paths ──────────────────────
2788
2789    /// Creates a repo view with two small commits (fits budget without split dispatch).
2790    fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2791        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2792        use crate::git::commit::FileChanges;
2793        use crate::git::{CommitAnalysis, CommitInfo};
2794
2795        let diff_a = dir.path().join("0.diff");
2796        let diff_b = dir.path().join("1.diff");
2797        std::fs::write(&diff_a, "+line a\n").unwrap();
2798        std::fs::write(&diff_b, "+line b\n").unwrap();
2799
2800        let hash_a = "a".repeat(40);
2801        let hash_b = "b".repeat(40);
2802
2803        crate::data::RepositoryView {
2804            versions: None,
2805            explanation: FieldExplanation::default(),
2806            working_directory: WorkingDirectoryInfo {
2807                clean: true,
2808                untracked_changes: Vec::new(),
2809            },
2810            remotes: Vec::new(),
2811            ai: AiInfo {
2812                scratch: String::new(),
2813            },
2814            branch_info: None,
2815            pr_template: None,
2816            pr_template_location: None,
2817            branch_prs: None,
2818            commits: vec![
2819                CommitInfo {
2820                    hash: hash_a,
2821                    author: "Test <test@test.com>".to_string(),
2822                    date: chrono::Utc::now().fixed_offset(),
2823                    original_message: "feat(a): add a".to_string(),
2824                    in_main_branches: Vec::new(),
2825                    analysis: CommitAnalysis {
2826                        detected_type: "feat".to_string(),
2827                        detected_scope: "a".to_string(),
2828                        proposed_message: "feat(a): add a".to_string(),
2829                        file_changes: FileChanges {
2830                            total_files: 1,
2831                            files_added: 1,
2832                            files_deleted: 0,
2833                            file_list: Vec::new(),
2834                        },
2835                        diff_summary: "a.rs | 1 +".to_string(),
2836                        diff_file: diff_a.to_string_lossy().to_string(),
2837                        file_diffs: Vec::new(),
2838                    },
2839                },
2840                CommitInfo {
2841                    hash: hash_b,
2842                    author: "Test <test@test.com>".to_string(),
2843                    date: chrono::Utc::now().fixed_offset(),
2844                    original_message: "feat(b): add b".to_string(),
2845                    in_main_branches: Vec::new(),
2846                    analysis: CommitAnalysis {
2847                        detected_type: "feat".to_string(),
2848                        detected_scope: "b".to_string(),
2849                        proposed_message: "feat(b): add b".to_string(),
2850                        file_changes: FileChanges {
2851                            total_files: 1,
2852                            files_added: 1,
2853                            files_deleted: 0,
2854                            file_list: Vec::new(),
2855                        },
2856                        diff_summary: "b.rs | 1 +".to_string(),
2857                        diff_file: diff_b.to_string_lossy().to_string(),
2858                        file_diffs: Vec::new(),
2859                    },
2860                },
2861            ],
2862        }
2863    }
2864
2865    #[tokio::test]
2866    async fn generate_amendments_multi_commit() {
2867        let dir = tempfile::tempdir().unwrap();
2868        let repo_view = make_multi_commit_repo_view(&dir);
2869        let hash_a = "a".repeat(40);
2870        let hash_b = "b".repeat(40);
2871
2872        let response = format!(
2873            concat!(
2874                "amendments:\n",
2875                "  - commit: \"{hash_a}\"\n",
2876                "    message: \"feat(a): improved a\"\n",
2877                "  - commit: \"{hash_b}\"\n",
2878                "    message: \"feat(b): improved b\"\n",
2879            ),
2880            hash_a = hash_a,
2881            hash_b = hash_b,
2882        );
2883        let client = make_configurable_client(vec![Ok(response)]);
2884
2885        let result = client
2886            .generate_amendments_with_options(&repo_view, false)
2887            .await;
2888
2889        assert!(
2890            result.is_ok(),
2891            "multi-commit amendment failed: {:?}",
2892            result.err()
2893        );
2894        let amendments = result.unwrap();
2895        assert_eq!(amendments.amendments.len(), 2);
2896    }
2897
2898    #[tokio::test]
2899    async fn generate_contextual_amendments_multi_commit() {
2900        let dir = tempfile::tempdir().unwrap();
2901        let repo_view = make_multi_commit_repo_view(&dir);
2902        let hash_a = "a".repeat(40);
2903        let hash_b = "b".repeat(40);
2904
2905        let response = format!(
2906            concat!(
2907                "amendments:\n",
2908                "  - commit: \"{hash_a}\"\n",
2909                "    message: \"feat(a): improved a\"\n",
2910                "  - commit: \"{hash_b}\"\n",
2911                "    message: \"feat(b): improved b\"\n",
2912            ),
2913            hash_a = hash_a,
2914            hash_b = hash_b,
2915        );
2916        let client = make_configurable_client(vec![Ok(response)]);
2917        let context = crate::data::context::CommitContext::default();
2918
2919        let result = client
2920            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2921            .await;
2922
2923        assert!(
2924            result.is_ok(),
2925            "multi-commit contextual amendment failed: {:?}",
2926            result.err()
2927        );
2928        let amendments = result.unwrap();
2929        assert_eq!(amendments.amendments.len(), 2);
2930    }
2931
2932    #[tokio::test]
2933    async fn generate_pr_content_succeeds() {
2934        let dir = tempfile::tempdir().unwrap();
2935        let repo_view = make_test_repo_view(&dir);
2936
2937        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2938        let client = make_configurable_client(vec![Ok(response.to_string())]);
2939
2940        let result = client.generate_pr_content(&repo_view, "").await;
2941
2942        assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2943        let pr = result.unwrap();
2944        assert_eq!(pr.title, "feat: add something");
2945        assert_eq!(pr.description, "Adds a new feature.");
2946    }
2947
2948    #[tokio::test]
2949    async fn generate_pr_content_with_context_succeeds() {
2950        let dir = tempfile::tempdir().unwrap();
2951        let repo_view = make_test_repo_view(&dir);
2952        let context = crate::data::context::CommitContext::default();
2953
2954        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2955        let client = make_configurable_client(vec![Ok(response.to_string())]);
2956
2957        let result = client
2958            .generate_pr_content_with_context(&repo_view, "", &context)
2959            .await;
2960
2961        assert!(
2962            result.is_ok(),
2963            "PR generation with context failed: {:?}",
2964            result.err()
2965        );
2966        let pr = result.unwrap();
2967        assert_eq!(pr.title, "feat: add something");
2968    }
2969
2970    #[tokio::test]
2971    async fn check_commits_multi_commit() {
2972        let dir = tempfile::tempdir().unwrap();
2973        let repo_view = make_multi_commit_repo_view(&dir);
2974        let hash_a = "a".repeat(40);
2975        let hash_b = "b".repeat(40);
2976
2977        let response = format!(
2978            concat!(
2979                "checks:\n",
2980                "  - commit: \"{hash_a}\"\n",
2981                "    passes: true\n",
2982                "    issues: []\n",
2983                "  - commit: \"{hash_b}\"\n",
2984                "    passes: true\n",
2985                "    issues: []\n",
2986            ),
2987            hash_a = hash_a,
2988            hash_b = hash_b,
2989        );
2990        let client = make_configurable_client(vec![Ok(response)]);
2991
2992        let result = client
2993            .check_commits_with_scopes(&repo_view, None, &[], false)
2994            .await;
2995
2996        assert!(
2997            result.is_ok(),
2998            "multi-commit check failed: {:?}",
2999            result.err()
3000        );
3001        let report = result.unwrap();
3002        assert_eq!(report.commits.len(), 2);
3003        assert!(report.commits[0].passes);
3004        assert!(report.commits[1].passes);
3005    }
3006
3007    // ── Multi-commit split dispatch helpers ──────────────────────────
3008
3009    /// Creates a repo view with two large-diff commits whose combined view
3010    /// exceeds the constrained 25KB context window.
3011    fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
3012        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3013        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3014        use crate::git::{CommitAnalysis, CommitInfo};
3015
3016        let hash_a = "a".repeat(40);
3017        let hash_b = "b".repeat(40);
3018
3019        // Write flat diff files large enough to bust the 50K-token budget when combined.
3020        // Each 60k chars ≈ 28,800 tokens; combined ≈ 57,600 > 41,808 available.
3021        let diff_content_a = "x".repeat(60_000);
3022        let diff_content_b = "y".repeat(60_000);
3023        let flat_a = dir.path().join("flat_a.diff");
3024        let flat_b = dir.path().join("flat_b.diff");
3025        std::fs::write(&flat_a, &diff_content_a).unwrap();
3026        std::fs::write(&flat_b, &diff_content_b).unwrap();
3027
3028        // Write per-file diff files for split dispatch
3029        let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3030        let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3031        let per_file_a = dir.path().join("pf_a.diff");
3032        let per_file_b = dir.path().join("pf_b.diff");
3033        std::fs::write(&per_file_a, &file_diff_a).unwrap();
3034        std::fs::write(&per_file_b, &file_diff_b).unwrap();
3035
3036        crate::data::RepositoryView {
3037            versions: None,
3038            explanation: FieldExplanation::default(),
3039            working_directory: WorkingDirectoryInfo {
3040                clean: true,
3041                untracked_changes: Vec::new(),
3042            },
3043            remotes: Vec::new(),
3044            ai: AiInfo {
3045                scratch: String::new(),
3046            },
3047            branch_info: None,
3048            pr_template: None,
3049            pr_template_location: None,
3050            branch_prs: None,
3051            commits: vec![
3052                CommitInfo {
3053                    hash: hash_a,
3054                    author: "Test <test@test.com>".to_string(),
3055                    date: chrono::Utc::now().fixed_offset(),
3056                    original_message: "feat(a): add module a".to_string(),
3057                    in_main_branches: Vec::new(),
3058                    analysis: CommitAnalysis {
3059                        detected_type: "feat".to_string(),
3060                        detected_scope: "a".to_string(),
3061                        proposed_message: "feat(a): add module a".to_string(),
3062                        file_changes: FileChanges {
3063                            total_files: 1,
3064                            files_added: 1,
3065                            files_deleted: 0,
3066                            file_list: vec![FileChange {
3067                                status: "A".to_string(),
3068                                file: "src/a.rs".to_string(),
3069                            }],
3070                        },
3071                        diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3072                        diff_file: flat_a.to_string_lossy().to_string(),
3073                        file_diffs: vec![FileDiffRef {
3074                            path: "src/a.rs".to_string(),
3075                            diff_file: per_file_a.to_string_lossy().to_string(),
3076                            byte_len: file_diff_a.len(),
3077                        }],
3078                    },
3079                },
3080                CommitInfo {
3081                    hash: hash_b,
3082                    author: "Test <test@test.com>".to_string(),
3083                    date: chrono::Utc::now().fixed_offset(),
3084                    original_message: "feat(b): add module b".to_string(),
3085                    in_main_branches: Vec::new(),
3086                    analysis: CommitAnalysis {
3087                        detected_type: "feat".to_string(),
3088                        detected_scope: "b".to_string(),
3089                        proposed_message: "feat(b): add module b".to_string(),
3090                        file_changes: FileChanges {
3091                            total_files: 1,
3092                            files_added: 1,
3093                            files_deleted: 0,
3094                            file_list: vec![FileChange {
3095                                status: "A".to_string(),
3096                                file: "src/b.rs".to_string(),
3097                            }],
3098                        },
3099                        diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3100                        diff_file: flat_b.to_string_lossy().to_string(),
3101                        file_diffs: vec![FileDiffRef {
3102                            path: "src/b.rs".to_string(),
3103                            diff_file: per_file_b.to_string_lossy().to_string(),
3104                            byte_len: file_diff_b.len(),
3105                        }],
3106                    },
3107                },
3108            ],
3109        }
3110    }
3111
3112    fn valid_pr_yaml(title: &str, description: &str) -> String {
3113        format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3114    }
3115
3116    // ── Multi-commit amendment split dispatch tests ──────────────────
3117
3118    #[tokio::test]
3119    async fn generate_amendments_multi_commit_split_dispatch() {
3120        let dir = tempfile::tempdir().unwrap();
3121        let repo_view = make_large_multi_commit_repo_view(&dir);
3122        let hash_a = "a".repeat(40);
3123        let hash_b = "b".repeat(40);
3124
3125        // Full view exceeds budget → per-commit fallback
3126        // Each commit fits individually (1 file each) → 1 response per commit
3127        let (client, handle) = make_small_context_client_tracked(vec![
3128            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3129            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3130        ]);
3131
3132        let result = client
3133            .generate_amendments_with_options(&repo_view, false)
3134            .await;
3135
3136        assert!(
3137            result.is_ok(),
3138            "multi-commit split dispatch failed: {:?}",
3139            result.err()
3140        );
3141        let amendments = result.unwrap();
3142        assert_eq!(amendments.amendments.len(), 2);
3143        assert_eq!(amendments.amendments[0].commit, hash_a);
3144        assert_eq!(amendments.amendments[1].commit, hash_b);
3145        assert!(amendments.amendments[0].message.contains("improved a"));
3146        assert!(amendments.amendments[1].message.contains("improved b"));
3147        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3148    }
3149
3150    #[tokio::test]
3151    async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3152        let dir = tempfile::tempdir().unwrap();
3153        let repo_view = make_large_multi_commit_repo_view(&dir);
3154        let hash_a = "a".repeat(40);
3155        let hash_b = "b".repeat(40);
3156        let context = crate::data::context::CommitContext::default();
3157
3158        let (client, handle) = make_small_context_client_tracked(vec![
3159            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3160            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3161        ]);
3162
3163        let result = client
3164            .generate_contextual_amendments_with_options(&repo_view, &context, false)
3165            .await;
3166
3167        assert!(
3168            result.is_ok(),
3169            "multi-commit contextual split dispatch failed: {:?}",
3170            result.err()
3171        );
3172        let amendments = result.unwrap();
3173        assert_eq!(amendments.amendments.len(), 2);
3174        assert_eq!(amendments.amendments[0].commit, hash_a);
3175        assert_eq!(amendments.amendments[1].commit, hash_b);
3176        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3177    }
3178
3179    // ── Multi-commit check split dispatch tests ──────────────────────
3180
3181    #[tokio::test]
3182    async fn check_commits_multi_commit_split_dispatch() {
3183        let dir = tempfile::tempdir().unwrap();
3184        let repo_view = make_large_multi_commit_repo_view(&dir);
3185        let hash_a = "a".repeat(40);
3186        let hash_b = "b".repeat(40);
3187
3188        // Full view exceeds budget → per-commit fallback
3189        let (client, handle) = make_small_context_client_tracked(vec![
3190            Ok(valid_check_yaml_for(&hash_a, true)),
3191            Ok(valid_check_yaml_for(&hash_b, true)),
3192        ]);
3193
3194        let result = client
3195            .check_commits_with_scopes(&repo_view, None, &[], false)
3196            .await;
3197
3198        assert!(
3199            result.is_ok(),
3200            "multi-commit check split dispatch failed: {:?}",
3201            result.err()
3202        );
3203        let report = result.unwrap();
3204        assert_eq!(report.commits.len(), 2);
3205        assert!(report.commits[0].passes);
3206        assert!(report.commits[1].passes);
3207        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3208    }
3209
3210    // ── PR split dispatch tests ──────────────────────────────────────
3211
3212    #[tokio::test]
3213    async fn generate_pr_content_split_dispatch() {
3214        let dir = tempfile::tempdir().unwrap();
3215        let repo_view = make_large_diff_repo_view(&dir);
3216
3217        // Single large commit: full view exceeds budget → per-commit fallback
3218        // 1 commit with 2 file chunks → chunk 1 + chunk 2 + chunk merge pass
3219        // Single per-commit result → returned directly (no extra merge)
3220        let (client, handle) = make_small_context_client_tracked(vec![
3221            Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3222            Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3223            Ok(valid_pr_yaml(
3224                "feat(test): add modules",
3225                "Adds a.rs and b.rs",
3226            )),
3227        ]);
3228
3229        let result = client.generate_pr_content(&repo_view, "").await;
3230
3231        assert!(
3232            result.is_ok(),
3233            "PR split dispatch failed: {:?}",
3234            result.err()
3235        );
3236        let pr = result.unwrap();
3237        assert!(pr.title.contains("add modules"));
3238        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3239    }
3240
3241    #[tokio::test]
3242    async fn generate_pr_content_multi_commit_split_dispatch() {
3243        let dir = tempfile::tempdir().unwrap();
3244        let repo_view = make_large_multi_commit_repo_view(&dir);
3245
3246        // Full view exceeds budget → per-commit fallback
3247        // Each commit fits individually → 1 response per commit, then merge pass
3248        let (client, handle) = make_small_context_client_tracked(vec![
3249            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3250            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3251            Ok(valid_pr_yaml(
3252                "feat: add modules a and b",
3253                "Adds both modules",
3254            )),
3255        ]);
3256
3257        let result = client.generate_pr_content(&repo_view, "").await;
3258
3259        assert!(
3260            result.is_ok(),
3261            "PR multi-commit split dispatch failed: {:?}",
3262            result.err()
3263        );
3264        let pr = result.unwrap();
3265        assert!(pr.title.contains("modules"));
3266        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3267    }
3268
3269    #[tokio::test]
3270    async fn generate_pr_content_with_context_split_dispatch() {
3271        let dir = tempfile::tempdir().unwrap();
3272        let repo_view = make_large_multi_commit_repo_view(&dir);
3273        let context = crate::data::context::CommitContext::default();
3274
3275        // Full view exceeds budget → per-commit fallback → merge pass
3276        let (client, handle) = make_small_context_client_tracked(vec![
3277            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3278            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3279            Ok(valid_pr_yaml(
3280                "feat: add modules a and b",
3281                "Adds both modules",
3282            )),
3283        ]);
3284
3285        let result = client
3286            .generate_pr_content_with_context(&repo_view, "", &context)
3287            .await;
3288
3289        assert!(
3290            result.is_ok(),
3291            "PR with context split dispatch failed: {:?}",
3292            result.err()
3293        );
3294        let pr = result.unwrap();
3295        assert!(pr.title.contains("modules"));
3296        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3297    }
3298
3299    // ── prompt-recording split dispatch tests ────────────────────
3300
3301    /// Like [`make_small_context_client_tracked`] but also returns a
3302    /// [`PromptRecordHandle`] for inspecting which prompts were sent.
3303    fn make_small_context_client_with_prompts(
3304        responses: Vec<Result<String>>,
3305    ) -> (
3306        ClaudeClient,
3307        crate::claude::test_utils::ResponseQueueHandle,
3308        crate::claude::test_utils::PromptRecordHandle,
3309    ) {
3310        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3311            .with_context_length(50_000);
3312        let response_handle = mock.response_handle();
3313        let prompt_handle = mock.prompt_handle();
3314        (
3315            ClaudeClient::new(Box::new(mock)),
3316            response_handle,
3317            prompt_handle,
3318        )
3319    }
3320
3321    /// Creates a default-context mock client that also records prompts.
3322    fn make_configurable_client_with_prompts(
3323        responses: Vec<Result<String>>,
3324    ) -> (
3325        ClaudeClient,
3326        crate::claude::test_utils::ResponseQueueHandle,
3327        crate::claude::test_utils::PromptRecordHandle,
3328    ) {
3329        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3330        let response_handle = mock.response_handle();
3331        let prompt_handle = mock.prompt_handle();
3332        (
3333            ClaudeClient::new(Box::new(mock)),
3334            response_handle,
3335            prompt_handle,
3336        )
3337    }
3338
3339    /// Creates a repo view with one commit containing a single large file
3340    /// whose diff exceeds the token budget. Because the per-file diff is
3341    /// loaded as a whole (hunk-level granularity from the packer is lost
3342    /// at the dispatch layer), the split dispatch path will fail with a
3343    /// budget error. This helper exists to test that the error propagates
3344    /// cleanly rather than silently degrading.
3345    fn make_single_oversized_file_repo_view(
3346        dir: &tempfile::TempDir,
3347    ) -> crate::data::RepositoryView {
3348        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3349        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3350        use crate::git::{CommitAnalysis, CommitInfo};
3351
3352        let hash = "c".repeat(40);
3353
3354        // A single file diff large enough (~80K bytes ≈ 25K tokens) to
3355        // exceed the 25K context window budget even for a single chunk.
3356        let diff_content = format!(
3357            "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3358            "x".repeat(80_000)
3359        );
3360
3361        let flat_diff_path = dir.path().join("full.diff");
3362        std::fs::write(&flat_diff_path, &diff_content).unwrap();
3363
3364        let per_file_path = dir.path().join("0000.diff");
3365        std::fs::write(&per_file_path, &diff_content).unwrap();
3366
3367        crate::data::RepositoryView {
3368            versions: None,
3369            explanation: FieldExplanation::default(),
3370            working_directory: WorkingDirectoryInfo {
3371                clean: true,
3372                untracked_changes: Vec::new(),
3373            },
3374            remotes: Vec::new(),
3375            ai: AiInfo {
3376                scratch: String::new(),
3377            },
3378            branch_info: None,
3379            pr_template: None,
3380            pr_template_location: None,
3381            branch_prs: None,
3382            commits: vec![CommitInfo {
3383                hash,
3384                author: "Test <test@test.com>".to_string(),
3385                date: chrono::Utc::now().fixed_offset(),
3386                original_message: "feat(big): add large module".to_string(),
3387                in_main_branches: Vec::new(),
3388                analysis: CommitAnalysis {
3389                    detected_type: "feat".to_string(),
3390                    detected_scope: "big".to_string(),
3391                    proposed_message: "feat(big): add large module".to_string(),
3392                    file_changes: FileChanges {
3393                        total_files: 1,
3394                        files_added: 1,
3395                        files_deleted: 0,
3396                        file_list: vec![FileChange {
3397                            status: "A".to_string(),
3398                            file: "src/big.rs".to_string(),
3399                        }],
3400                    },
3401                    diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3402                    diff_file: flat_diff_path.to_string_lossy().to_string(),
3403                    file_diffs: vec![FileDiffRef {
3404                        path: "src/big.rs".to_string(),
3405                        diff_file: per_file_path.to_string_lossy().to_string(),
3406                        byte_len: diff_content.len(),
3407                    }],
3408                },
3409            }],
3410        }
3411    }
3412
3413    /// A small single-file commit whose diff fits within the token budget.
3414    ///
3415    /// Exercises the non-split path: `generate_amendments_with_options` →
3416    /// `try_full_diff_budget` succeeds → single AI request → amendment
3417    /// returned directly. Verifies exactly one request is made and the
3418    /// user prompt contains the actual diff content.
3419    #[tokio::test]
3420    async fn amendment_single_file_under_budget_no_split() {
3421        let dir = tempfile::tempdir().unwrap();
3422        let repo_view = make_test_repo_view(&dir);
3423        let hash = format!("{:0>40}", 0);
3424
3425        let (client, response_handle, prompt_handle) =
3426            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3427                &hash,
3428                "feat(test): improved message",
3429            ))]);
3430
3431        let result = client
3432            .generate_amendments_with_options(&repo_view, false)
3433            .await;
3434
3435        assert!(result.is_ok());
3436        assert_eq!(result.unwrap().amendments.len(), 1);
3437        assert_eq!(response_handle.remaining(), 0);
3438
3439        let prompts = prompt_handle.prompts();
3440        assert_eq!(
3441            prompts.len(),
3442            1,
3443            "expected exactly one AI request, no split"
3444        );
3445
3446        let (_, user_prompt) = &prompts[0];
3447        assert!(
3448            user_prompt.contains("added line"),
3449            "user prompt should contain the diff content"
3450        );
3451    }
3452
3453    /// A two-file commit that exceeds the token budget when combined.
3454    ///
3455    /// Exercises the file-level split path: `generate_amendments_with_options`
3456    /// → `try_full_diff_budget` fails → `generate_amendment_for_commit` →
3457    /// `try_full_diff_budget` fails again → `generate_amendment_split` →
3458    /// `pack_file_diffs` creates 2 chunks (one file each) → 2 AI requests
3459    /// → `merge_amendment_chunks` reduce pass → 1 merged amendment.
3460    ///
3461    /// Verifies that each chunk's user prompt contains only its file's diff
3462    /// content, and the merge prompt contains both partial amendment messages.
3463    #[tokio::test]
3464    async fn amendment_two_chunks_prompt_content() {
3465        let dir = tempfile::tempdir().unwrap();
3466        let repo_view = make_large_diff_repo_view(&dir);
3467        let hash = "a".repeat(40);
3468
3469        let (client, response_handle, prompt_handle) =
3470            make_small_context_client_with_prompts(vec![
3471                Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3472                Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3473                Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3474            ]);
3475
3476        let result = client
3477            .generate_amendments_with_options(&repo_view, false)
3478            .await;
3479
3480        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3481        let amendments = result.unwrap();
3482        assert_eq!(amendments.amendments.len(), 1);
3483        assert!(amendments.amendments[0]
3484            .message
3485            .contains("add a.rs and b.rs"));
3486        assert_eq!(response_handle.remaining(), 0);
3487
3488        let prompts = prompt_handle.prompts();
3489        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3490
3491        // Chunk 1 should contain file-a diff content (repeated 'a' chars)
3492        let (_, chunk1_user) = &prompts[0];
3493        assert!(
3494            chunk1_user.contains("aaa"),
3495            "chunk 1 prompt should contain file-a diff content"
3496        );
3497
3498        // Chunk 2 should contain file-b diff content (repeated 'b' chars)
3499        let (_, chunk2_user) = &prompts[1];
3500        assert!(
3501            chunk2_user.contains("bbb"),
3502            "chunk 2 prompt should contain file-b diff content"
3503        );
3504
3505        // Merge pass: system prompt is the synthesis prompt
3506        let (merge_sys, merge_user) = &prompts[2];
3507        assert!(
3508            merge_sys.contains("synthesiz"),
3509            "merge system prompt should contain synthesis instructions"
3510        );
3511        // Merge user prompt should contain both partial messages
3512        assert!(
3513            merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3514            "merge user prompt should contain both partial amendment messages"
3515        );
3516    }
3517
3518    /// A single file whose diff exceeds the budget even after split dispatch.
3519    ///
3520    /// Exercises the budget-error path: `generate_amendment_for_commit` →
3521    /// budget exceeded → `generate_amendment_split` → `pack_file_diffs`
3522    /// plans hunk-level chunks → but `from_commit_info_partial` loads the
3523    /// full per-file diff (deduplicates the repeated path) →
3524    /// Oversized files that can't be split get placeholders and proceed.
3525    ///
3526    /// Verifies that files too large for the budget are replaced with
3527    /// placeholder text indicating the file was omitted, rather than
3528    /// failing with a "prompt too large" error.
3529    #[tokio::test]
3530    async fn amendment_single_oversized_file_gets_placeholder() {
3531        let dir = tempfile::tempdir().unwrap();
3532        let repo_view = make_single_oversized_file_repo_view(&dir);
3533        let hash = "c".repeat(40);
3534
3535        // The file is too large for the full budget but gets a placeholder.
3536        // With 50k context, the placeholder is small enough to fit in a
3537        // single request. Provide a second response in case the system prompt
3538        // is large enough to trigger split dispatch.
3539        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3540            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3541            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3542        ]);
3543
3544        let result = client
3545            .generate_amendments_with_options(&repo_view, false)
3546            .await;
3547
3548        // Should succeed (either single request or split with placeholder)
3549        assert!(
3550            result.is_ok(),
3551            "expected success with placeholder, got: {result:?}"
3552        );
3553
3554        // One request (placeholder makes it fit in single request)
3555        assert!(
3556            prompt_handle.request_count() >= 1,
3557            "expected at least 1 request, got {}",
3558            prompt_handle.request_count()
3559        );
3560    }
3561
3562    /// A two-chunk split where the second chunk's AI request fails.
3563    ///
3564    /// Exercises the error-propagation path within `generate_amendment_split`:
3565    /// chunk 1 succeeds → chunk 2 returns `Err` → the `?` operator in the
3566    /// loop body propagates the error immediately, skipping the merge pass.
3567    ///
3568    /// Verifies that exactly 2 requests are recorded (no further processing)
3569    /// and the overall result is `Err` (no silent degradation).
3570    #[tokio::test]
3571    async fn amendment_chunk_failure_stops_dispatch() {
3572        let dir = tempfile::tempdir().unwrap();
3573        let repo_view = make_large_diff_repo_view(&dir);
3574        let hash = "a".repeat(40);
3575
3576        // First chunk succeeds, second chunk fails
3577        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3578            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3579            Err(anyhow::anyhow!("rate limit exceeded")),
3580        ]);
3581
3582        let result = client
3583            .generate_amendments_with_options(&repo_view, false)
3584            .await;
3585
3586        assert!(result.is_err());
3587
3588        // Exactly 2 requests: chunk 1 (success) + chunk 2 (failure)
3589        let prompts = prompt_handle.prompts();
3590        assert_eq!(
3591            prompts.len(),
3592            2,
3593            "should stop after the failing chunk, got {} requests",
3594            prompts.len()
3595        );
3596
3597        // The first request should reference one of the files
3598        let (_, first_user) = &prompts[0];
3599        assert!(
3600            first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3601            "first chunk prompt should reference a file"
3602        );
3603    }
3604
3605    /// Two-chunk amendment split dispatch, focused on the reduce pass inputs.
3606    ///
3607    /// Exercises `merge_amendment_chunks` which calls
3608    /// `generate_chunk_merge_user_prompt` to assemble the merge prompt from:
3609    /// the commit hash, original message, diff_summary, and the partial
3610    /// amendment messages returned by each chunk.
3611    ///
3612    /// Verifies that the merge (3rd) request's user prompt contains all of:
3613    /// both partial messages, the original commit message, the diff_summary
3614    /// file paths, and the commit hash.
3615    #[tokio::test]
3616    async fn amendment_reduce_pass_prompt_content() {
3617        let dir = tempfile::tempdir().unwrap();
3618        let repo_view = make_large_diff_repo_view(&dir);
3619        let hash = "a".repeat(40);
3620
3621        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3622            Ok(valid_amendment_yaml(
3623                &hash,
3624                "feat(a): add module a implementation",
3625            )),
3626            Ok(valid_amendment_yaml(
3627                &hash,
3628                "feat(b): add module b implementation",
3629            )),
3630            Ok(valid_amendment_yaml(
3631                &hash,
3632                "feat(test): add modules a and b",
3633            )),
3634        ]);
3635
3636        let result = client
3637            .generate_amendments_with_options(&repo_view, false)
3638            .await;
3639
3640        assert!(result.is_ok());
3641
3642        let prompts = prompt_handle.prompts();
3643        assert_eq!(prompts.len(), 3);
3644
3645        // The merge pass is the last (3rd) request
3646        let (merge_system, merge_user) = &prompts[2];
3647
3648        // System prompt should be the amendment chunk merge prompt
3649        assert!(
3650            merge_system.contains("synthesiz"),
3651            "merge system prompt should contain synthesis instructions"
3652        );
3653
3654        // User prompt should contain the partial messages from chunks
3655        assert!(
3656            merge_user.contains("feat(a): add module a implementation"),
3657            "merge user prompt should contain chunk 1's partial message"
3658        );
3659        assert!(
3660            merge_user.contains("feat(b): add module b implementation"),
3661            "merge user prompt should contain chunk 2's partial message"
3662        );
3663
3664        // User prompt should contain the original commit message
3665        assert!(
3666            merge_user.contains("feat(test): large commit"),
3667            "merge user prompt should contain the original commit message"
3668        );
3669
3670        // User prompt should contain the diff_summary referencing both files
3671        assert!(
3672            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3673            "merge user prompt should contain the diff_summary"
3674        );
3675
3676        // User prompt should reference the commit hash
3677        assert!(
3678            merge_user.contains(&hash),
3679            "merge user prompt should reference the commit hash"
3680        );
3681    }
3682
3683    /// Two-chunk check split dispatch with issue deduplication and merge.
3684    ///
3685    /// Exercises `check_commit_split` which:
3686    /// 1. Dispatches 2 chunk requests (one per file)
3687    /// 2. Collects issues from both chunks into a `HashSet` keyed by
3688    ///    `(rule, severity, section)` — duplicates are dropped
3689    /// 3. Detects that both chunks have suggestions → calls
3690    ///    `merge_check_chunks` for the AI reduce pass
3691    ///
3692    /// Chunk 1 reports: `error:imperative-mood:Subject Line` +
3693    ///                   `warning:body-required:Content`
3694    /// Chunk 2 reports: `error:imperative-mood:Subject Line` (duplicate) +
3695    ///                   `info:scope-suggestion:Style` (new)
3696    ///
3697    /// Verifies: 3 unique issues after dedup, suggestion from merge pass,
3698    /// and the merge prompt contains both partial suggestions + diff_summary.
3699    #[tokio::test]
3700    async fn check_split_dedup_and_merge_prompt() {
3701        let dir = tempfile::tempdir().unwrap();
3702        let repo_view = make_large_diff_repo_view(&dir);
3703        let hash = "a".repeat(40);
3704
3705        // Chunk 1: error (imperative-mood) + warning (body-required) + suggestion
3706        let chunk1_yaml = format!(
3707            concat!(
3708                "checks:\n",
3709                "  - commit: \"{hash}\"\n",
3710                "    passes: false\n",
3711                "    issues:\n",
3712                "      - severity: error\n",
3713                "        section: \"Subject Line\"\n",
3714                "        rule: \"imperative-mood\"\n",
3715                "        explanation: \"Subject uses past tense\"\n",
3716                "      - severity: warning\n",
3717                "        section: \"Content\"\n",
3718                "        rule: \"body-required\"\n",
3719                "        explanation: \"Large change needs body\"\n",
3720                "    suggestion:\n",
3721                "      message: \"feat(a): shorter subject for a\"\n",
3722                "      explanation: \"Shortened subject for file a\"\n",
3723                "    summary: \"Adds module a\"\n",
3724            ),
3725            hash = hash,
3726        );
3727
3728        // Chunk 2: same error (different explanation) + new info issue + suggestion
3729        let chunk2_yaml = format!(
3730            concat!(
3731                "checks:\n",
3732                "  - commit: \"{hash}\"\n",
3733                "    passes: false\n",
3734                "    issues:\n",
3735                "      - severity: error\n",
3736                "        section: \"Subject Line\"\n",
3737                "        rule: \"imperative-mood\"\n",
3738                "        explanation: \"Subject line is way too long\"\n",
3739                "      - severity: info\n",
3740                "        section: \"Style\"\n",
3741                "        rule: \"scope-suggestion\"\n",
3742                "        explanation: \"Consider more specific scope\"\n",
3743                "    suggestion:\n",
3744                "      message: \"feat(b): shorter subject for b\"\n",
3745                "      explanation: \"Shortened subject for file b\"\n",
3746                "    summary: \"Adds module b\"\n",
3747            ),
3748            hash = hash,
3749        );
3750
3751        // Merge pass (called because suggestions exist)
3752        let merge_yaml = format!(
3753            concat!(
3754                "checks:\n",
3755                "  - commit: \"{hash}\"\n",
3756                "    passes: false\n",
3757                "    issues: []\n",
3758                "    suggestion:\n",
3759                "      message: \"feat(test): add modules a and b\"\n",
3760                "      explanation: \"Combined suggestion\"\n",
3761                "    summary: \"Adds modules a and b\"\n",
3762            ),
3763            hash = hash,
3764        );
3765
3766        let (client, response_handle, prompt_handle) =
3767            make_small_context_client_with_prompts(vec![
3768                Ok(chunk1_yaml),
3769                Ok(chunk2_yaml),
3770                Ok(merge_yaml),
3771            ]);
3772
3773        let result = client
3774            .check_commits_with_scopes(&repo_view, None, &[], true)
3775            .await;
3776
3777        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3778        let report = result.unwrap();
3779        assert_eq!(report.commits.len(), 1);
3780        assert!(!report.commits[0].passes);
3781        assert_eq!(response_handle.remaining(), 0);
3782
3783        // Dedup: 3 unique (rule, severity, section) tuples
3784        //  - imperative-mood / error / Subject Line   (appears in both → deduped)
3785        //  - body-required    / warning / Content
3786        //  - scope-suggestion / info / Style
3787        assert_eq!(
3788            report.commits[0].issues.len(),
3789            3,
3790            "expected 3 unique issues after dedup, got {:?}",
3791            report.commits[0]
3792                .issues
3793                .iter()
3794                .map(|i| &i.rule)
3795                .collect::<Vec<_>>()
3796        );
3797
3798        // Suggestion should come from the merge pass
3799        assert!(report.commits[0].suggestion.is_some());
3800        assert!(
3801            report.commits[0]
3802                .suggestion
3803                .as_ref()
3804                .unwrap()
3805                .message
3806                .contains("add modules a and b"),
3807            "suggestion should come from the merge pass"
3808        );
3809
3810        // Prompt content assertions
3811        let prompts = prompt_handle.prompts();
3812        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3813
3814        // Chunk prompts should collectively cover both files
3815        let (_, chunk1_user) = &prompts[0];
3816        let (_, chunk2_user) = &prompts[1];
3817        let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3818        assert!(
3819            combined_chunk_prompts.contains("src/a.rs")
3820                && combined_chunk_prompts.contains("src/b.rs"),
3821            "chunk prompts should collectively cover both files"
3822        );
3823
3824        // Merge pass prompt should contain partial suggestions
3825        let (merge_sys, merge_user) = &prompts[2];
3826        assert!(
3827            merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3828            "merge system prompt should be the check chunk merge prompt"
3829        );
3830        assert!(
3831            merge_user.contains("feat(a): shorter subject for a")
3832                && merge_user.contains("feat(b): shorter subject for b"),
3833            "merge user prompt should contain both partial suggestions"
3834        );
3835        // Merge prompt should contain the diff_summary
3836        assert!(
3837            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3838            "merge user prompt should contain the diff_summary"
3839        );
3840    }
3841
3842    // ── Amendment retry tests ──────────────────────────────────────────
3843
3844    #[tokio::test]
3845    async fn amendment_retry_parse_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            Ok("not valid yaml {{[".to_string()),
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, "both responses consumed");
3866        assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3867    }
3868
3869    #[tokio::test]
3870    async fn amendment_retry_request_failure_then_success() {
3871        let dir = tempfile::tempdir().unwrap();
3872        let repo_view = make_test_repo_view(&dir);
3873        let hash = format!("{:0>40}", 0);
3874
3875        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3876            Err(anyhow::anyhow!("rate limit")),
3877            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3878        ]);
3879
3880        let result = client
3881            .generate_amendments_with_options(&repo_view, false)
3882            .await;
3883
3884        assert!(
3885            result.is_ok(),
3886            "should succeed after retry: {:?}",
3887            result.err()
3888        );
3889        assert_eq!(result.unwrap().amendments.len(), 1);
3890        assert_eq!(response_handle.remaining(), 0);
3891        assert_eq!(prompt_handle.request_count(), 2);
3892    }
3893
3894    #[tokio::test]
3895    async fn amendment_retry_all_attempts_exhausted() {
3896        let dir = tempfile::tempdir().unwrap();
3897        let repo_view = make_test_repo_view(&dir);
3898
3899        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3900            Ok("bad yaml 1".to_string()),
3901            Ok("bad yaml 2".to_string()),
3902            Ok("bad yaml 3".to_string()),
3903        ]);
3904
3905        let result = client
3906            .generate_amendments_with_options(&repo_view, false)
3907            .await;
3908
3909        assert!(result.is_err(), "should fail after all retries exhausted");
3910        assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3911        assert_eq!(
3912            prompt_handle.request_count(),
3913            3,
3914            "exactly 3 AI requests (1 + 2 retries)"
3915        );
3916    }
3917
3918    #[tokio::test]
3919    async fn amendment_retry_success_first_attempt() {
3920        let dir = tempfile::tempdir().unwrap();
3921        let repo_view = make_test_repo_view(&dir);
3922        let hash = format!("{:0>40}", 0);
3923
3924        let (client, response_handle, prompt_handle) =
3925            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3926                &hash,
3927                "feat(test): works first time",
3928            ))]);
3929
3930        let result = client
3931            .generate_amendments_with_options(&repo_view, false)
3932            .await;
3933
3934        assert!(result.is_ok());
3935        assert_eq!(response_handle.remaining(), 0);
3936        assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3937    }
3938
3939    #[tokio::test]
3940    async fn amendment_retry_mixed_request_and_parse_failures() {
3941        let dir = tempfile::tempdir().unwrap();
3942        let repo_view = make_test_repo_view(&dir);
3943        let hash = format!("{:0>40}", 0);
3944
3945        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3946            Err(anyhow::anyhow!("network error")),
3947            Ok("invalid yaml {{".to_string()),
3948            Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3949        ]);
3950
3951        let result = client
3952            .generate_amendments_with_options(&repo_view, false)
3953            .await;
3954
3955        assert!(
3956            result.is_ok(),
3957            "should succeed on third attempt: {:?}",
3958            result.err()
3959        );
3960        assert_eq!(result.unwrap().amendments.len(), 1);
3961        assert_eq!(response_handle.remaining(), 0);
3962        assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3963    }
3964
3965    // ── create_default_claude_client factory ───────────────────────
3966
3967    /// Serialises env-mutating factory tests in this module.
3968    static FACTORY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3969
3970    struct FactoryEnvGuard {
3971        _lock: std::sync::MutexGuard<'static, ()>,
3972        saved: Vec<(&'static str, Option<String>)>,
3973    }
3974
3975    impl FactoryEnvGuard {
3976        fn new(keys: &[&'static str]) -> Self {
3977            let lock = FACTORY_ENV_LOCK
3978                .lock()
3979                .unwrap_or_else(std::sync::PoisonError::into_inner);
3980            let saved = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
3981            for k in keys {
3982                std::env::remove_var(k);
3983            }
3984            Self { _lock: lock, saved }
3985        }
3986
3987        fn set(&self, key: &str, value: &str) {
3988            std::env::set_var(key, value);
3989        }
3990    }
3991
3992    impl Drop for FactoryEnvGuard {
3993        fn drop(&mut self) {
3994            for (k, v) in self.saved.drain(..) {
3995                match v {
3996                    Some(val) => std::env::set_var(k, val),
3997                    None => std::env::remove_var(k),
3998                }
3999            }
4000        }
4001    }
4002
4003    #[tokio::test]
4004    async fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
4005        let guard = FactoryEnvGuard::new(&[
4006            "OMNI_DEV_AI_BACKEND",
4007            "USE_OPENAI",
4008            "USE_OLLAMA",
4009            "CLAUDE_CODE_USE_BEDROCK",
4010            "CLAUDE_MODEL",
4011            "CLAUDE_CODE_MODEL",
4012            "ANTHROPIC_MODEL",
4013        ]);
4014        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4015
4016        let client = create_default_claude_client(None, None)
4017            .await
4018            .expect("factory should succeed");
4019        let metadata = client.get_ai_client_metadata();
4020        assert_eq!(metadata.provider, "Claude CLI");
4021        // Default model falls through to the registry's claude default.
4022        assert_eq!(metadata.model, "claude-sonnet-4-6");
4023    }
4024
4025    #[tokio::test]
4026    async fn factory_claude_cli_backend_honours_model_precedence() {
4027        let guard = FactoryEnvGuard::new(&[
4028            "OMNI_DEV_AI_BACKEND",
4029            "USE_OPENAI",
4030            "USE_OLLAMA",
4031            "CLAUDE_CODE_USE_BEDROCK",
4032            "CLAUDE_MODEL",
4033            "CLAUDE_CODE_MODEL",
4034            "ANTHROPIC_MODEL",
4035        ]);
4036        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4037        guard.set("CLAUDE_CODE_MODEL", "opus");
4038        // CLAUDE_MODEL has higher precedence than CLAUDE_CODE_MODEL.
4039        guard.set("CLAUDE_MODEL", "haiku");
4040
4041        let client = create_default_claude_client(None, None)
4042            .await
4043            .expect("factory should succeed");
4044        let metadata = client.get_ai_client_metadata();
4045        assert_eq!(metadata.provider, "Claude CLI");
4046        assert_eq!(metadata.model, "haiku");
4047    }
4048
4049    #[tokio::test]
4050    async fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4051        let guard = FactoryEnvGuard::new(&[
4052            "OMNI_DEV_AI_BACKEND",
4053            "USE_OPENAI",
4054            "USE_OLLAMA",
4055            "CLAUDE_CODE_USE_BEDROCK",
4056            "CLAUDE_MODEL",
4057            "CLAUDE_CODE_MODEL",
4058            "ANTHROPIC_MODEL",
4059        ]);
4060        guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4061        guard.set("CLAUDE_MODEL", "haiku");
4062
4063        let client = create_default_claude_client(Some("opus".to_string()), None)
4064            .await
4065            .expect("factory should succeed");
4066        let metadata = client.get_ai_client_metadata();
4067        assert_eq!(metadata.model, "opus");
4068    }
4069
4070    #[tokio::test]
4071    async fn factory_claude_cli_backend_accepts_underscore_alias() {
4072        let guard = FactoryEnvGuard::new(&[
4073            "OMNI_DEV_AI_BACKEND",
4074            "USE_OPENAI",
4075            "USE_OLLAMA",
4076            "CLAUDE_CODE_USE_BEDROCK",
4077            "CLAUDE_MODEL",
4078            "CLAUDE_CODE_MODEL",
4079            "ANTHROPIC_MODEL",
4080        ]);
4081        guard.set("OMNI_DEV_AI_BACKEND", "claude_cli");
4082
4083        let client = create_default_claude_client(None, None)
4084            .await
4085            .expect("factory should succeed");
4086        let metadata = client.get_ai_client_metadata();
4087        assert_eq!(metadata.provider, "Claude CLI");
4088    }
4089
4090    #[tokio::test]
4091    async fn factory_ollama_branch_probes_loaded_context_length() {
4092        use wiremock::matchers::{method, path};
4093        use wiremock::{Mock, MockServer, ResponseTemplate};
4094
4095        let server = MockServer::start().await;
4096        Mock::given(method("GET"))
4097            .and(path("/api/v0/models"))
4098            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4099                "data": [
4100                    { "id": "lm-loaded", "state": "loaded", "loaded_context_length": 6144_u64 }
4101                ]
4102            })))
4103            .mount(&server)
4104            .await;
4105
4106        let guard = FactoryEnvGuard::new(&[
4107            "OMNI_DEV_AI_BACKEND",
4108            "USE_OPENAI",
4109            "USE_OLLAMA",
4110            "CLAUDE_CODE_USE_BEDROCK",
4111            "OLLAMA_BASE_URL",
4112            "OLLAMA_MODEL",
4113        ]);
4114        guard.set("USE_OLLAMA", "true");
4115        guard.set("OLLAMA_BASE_URL", &server.uri());
4116        guard.set("OLLAMA_MODEL", "lm-loaded");
4117
4118        let client = create_default_claude_client(None, None)
4119            .await
4120            .expect("factory should succeed");
4121        let metadata = client.get_ai_client_metadata();
4122        assert_eq!(metadata.provider, "Ollama");
4123        assert_eq!(metadata.model, "lm-loaded");
4124        // The probed value (6144) overrides the registry/default.
4125        assert_eq!(metadata.max_context_length, 6144);
4126    }
4127
4128    #[tokio::test]
4129    async fn factory_ollama_branch_falls_back_when_probe_fails() {
4130        use wiremock::matchers::{method, path};
4131        use wiremock::{Mock, MockServer, ResponseTemplate};
4132
4133        let server = MockServer::start().await;
4134        Mock::given(method("GET"))
4135            .and(path("/api/v0/models"))
4136            .respond_with(ResponseTemplate::new(500))
4137            .mount(&server)
4138            .await;
4139        Mock::given(method("POST"))
4140            .and(path("/api/show"))
4141            .respond_with(ResponseTemplate::new(500))
4142            .mount(&server)
4143            .await;
4144
4145        let guard = FactoryEnvGuard::new(&[
4146            "OMNI_DEV_AI_BACKEND",
4147            "USE_OPENAI",
4148            "USE_OLLAMA",
4149            "CLAUDE_CODE_USE_BEDROCK",
4150            "OLLAMA_BASE_URL",
4151            "OLLAMA_MODEL",
4152        ]);
4153        guard.set("USE_OLLAMA", "true");
4154        guard.set("OLLAMA_BASE_URL", &server.uri());
4155        guard.set("OLLAMA_MODEL", "no-such-model");
4156
4157        let client = create_default_claude_client(None, None)
4158            .await
4159            .expect("factory should succeed");
4160        let metadata = client.get_ai_client_metadata();
4161        // Probe failure → fall back to the registry estimate (which
4162        // resolves to FALLBACK_INPUT_CONTEXT for unknown models).
4163        let registry_value =
4164            crate::claude::model_config::get_model_registry().get_input_context("no-such-model");
4165        assert_eq!(metadata.max_context_length, registry_value);
4166    }
4167
4168    /// LM Studio path is tested above. This complements it by exercising
4169    /// the Ollama-native fallthrough through the factory, so the
4170    /// info-log arm fires for both `ProbeSource` variants.
4171    #[tokio::test]
4172    async fn factory_ollama_branch_probes_via_ollama_native() {
4173        use wiremock::matchers::{method, path};
4174        use wiremock::{Mock, MockServer, ResponseTemplate};
4175
4176        let server = MockServer::start().await;
4177        Mock::given(method("GET"))
4178            .and(path("/api/v0/models"))
4179            .respond_with(ResponseTemplate::new(404))
4180            .mount(&server)
4181            .await;
4182        Mock::given(method("POST"))
4183            .and(path("/api/show"))
4184            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4185                "model_info": { "llama.context_length": 12288_u64 }
4186            })))
4187            .mount(&server)
4188            .await;
4189
4190        let guard = FactoryEnvGuard::new(&[
4191            "OMNI_DEV_AI_BACKEND",
4192            "USE_OPENAI",
4193            "USE_OLLAMA",
4194            "CLAUDE_CODE_USE_BEDROCK",
4195            "OLLAMA_BASE_URL",
4196            "OLLAMA_MODEL",
4197        ]);
4198        guard.set("USE_OLLAMA", "true");
4199        guard.set("OLLAMA_BASE_URL", &server.uri());
4200        guard.set("OLLAMA_MODEL", "ollama-native-model");
4201
4202        let client = create_default_claude_client(None, None)
4203            .await
4204            .expect("factory should succeed");
4205        let metadata = client.get_ai_client_metadata();
4206        assert_eq!(metadata.max_context_length, 12288);
4207    }
4208}