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