Skip to main content

omni_dev/claude/
client.rs

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