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    /// extract the underlying client via this method rather than
53    /// 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    create_default_claude_client_with(
1751        &crate::utils::settings::SettingsEnv::load(),
1752        model,
1753        beta_header,
1754    )
1755    .await
1756}
1757
1758/// [`create_default_claude_client`] over an injected
1759/// [`EnvSource`](crate::utils::env::EnvSource).
1760///
1761/// This is the backend-dispatch boundary. Backend, model, and beta-header
1762/// selection delegate to the shared resolvers in [`crate::claude::backend`]
1763/// (also used by preflight); this function reads only the per-backend
1764/// credential/endpoint vars and builds the client. The production wrapper
1765/// passes `&SettingsEnv::load()`; tests pass a pure `MapEnv`, so dispatch is
1766/// tested without mutating the process environment or taking a lock
1767/// (issue #1030). The `Sync` bound keeps the returned future `Send` (the
1768/// reference is held across the Ollama context-length probe `.await`).
1769pub(crate) async fn create_default_claude_client_with(
1770    env: &(impl crate::utils::env::EnvSource + Sync),
1771    model: Option<String>,
1772    beta_header: Option<(String, String)>,
1773) -> Result<ClaudeClient> {
1774    use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1775    use crate::claude::ai::openai::OpenAiAiClient;
1776    use crate::claude::backend::{self, AiBackend};
1777
1778    let ai_backend = backend::resolve_backend(env)?;
1779    let beta_header = backend::resolve_beta_header(beta_header, env)?;
1780    let registry = crate::claude::model_config::get_model_registry();
1781    let model = backend::resolve_model(ai_backend, model.as_deref(), env, registry);
1782    debug!(backend = ?ai_backend, model = %model, "Resolved AI backend");
1783
1784    match ai_backend {
1785        AiBackend::ClaudeCli => {
1786            // The `claude -p` subprocess negotiates betas itself, so the
1787            // beta header is deliberately not forwarded (and, uniquely, not
1788            // validated — this backend accepts short model aliases like
1789            // sonnet/opus/haiku that the registry does not know).
1790            if beta_header.is_some() {
1791                warn!(
1792                    "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1793                     (the CLI's --betas flag has different semantics and is not forwarded)"
1794                );
1795            }
1796            debug!(model = %model, "Creating claude -p subprocess client");
1797            let ai_client = ClaudeCliAiClient::new(model);
1798            Ok(ClaudeClient::new(Box::new(ai_client)))
1799        }
1800        AiBackend::Ollama => {
1801            validate_beta_header(&model, &beta_header)?;
1802            let base_url = env.var("OLLAMA_BASE_URL");
1803            let mut ai_client = OpenAiAiClient::new_ollama(model, base_url, beta_header)?;
1804            match ai_client.probe_loaded_context_length().await {
1805                Some(source) => {
1806                    info!(
1807                        loaded_context_length = ai_client.loaded_context_length(),
1808                        source = source.as_str(),
1809                        model = %ai_client.get_metadata().model,
1810                        "Probed loaded context length from local server"
1811                    );
1812                }
1813                None => {
1814                    debug!(
1815                        "Loaded context length probe did not return a value; \
1816                         falling back to registry/default for token budget"
1817                    );
1818                }
1819            }
1820            Ok(ClaudeClient::new(Box::new(ai_client)))
1821        }
1822        AiBackend::OpenAi => {
1823            debug!("Creating OpenAI client");
1824            validate_beta_header(&model, &beta_header)?;
1825
1826            let api_key = env
1827                .var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
1828                .ok_or_else(|| {
1829                    debug!("Failed to get OpenAI API key");
1830                    ClaudeError::ApiKeyNotFound
1831                })?;
1832            debug!("OpenAI API key found");
1833
1834            let ai_client = OpenAiAiClient::new_openai(model, api_key, beta_header)?;
1835            debug!("OpenAI client created successfully");
1836            Ok(ClaudeClient::new(Box::new(ai_client)))
1837        }
1838        AiBackend::Bedrock => {
1839            validate_beta_header(&model, &beta_header)?;
1840            let auth_token = env
1841                .var("ANTHROPIC_AUTH_TOKEN")
1842                .ok_or(ClaudeError::ApiKeyNotFound)?;
1843
1844            let base_url = env
1845                .var("ANTHROPIC_BEDROCK_BASE_URL")
1846                .ok_or(ClaudeError::ApiKeyNotFound)?;
1847
1848            let ai_client = BedrockAiClient::new(model, auth_token, base_url, beta_header)?;
1849            Ok(ClaudeClient::new(Box::new(ai_client)))
1850        }
1851        AiBackend::Default => {
1852            debug!("Creating direct Claude API client");
1853            validate_beta_header(&model, &beta_header)?;
1854            let api_key = env
1855                .var_any(&[
1856                    "CLAUDE_API_KEY",
1857                    "ANTHROPIC_API_KEY",
1858                    "ANTHROPIC_AUTH_TOKEN",
1859                ])
1860                .ok_or(ClaudeError::ApiKeyNotFound)?;
1861
1862            let ai_client = ClaudeAiClient::new(model, api_key, beta_header)?;
1863            debug!("Claude client created successfully");
1864            Ok(ClaudeClient::new(Box::new(ai_client)))
1865        }
1866    }
1867}
1868
1869#[cfg(test)]
1870#[allow(
1871    clippy::unwrap_used,
1872    clippy::expect_used,
1873    clippy::format_in_format_args
1874)]
1875mod tests {
1876    use super::*;
1877    use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1878    use std::future::Future;
1879    use std::pin::Pin;
1880    use std::sync::{Arc, Mutex};
1881
1882    /// Mock AI client for testing — never makes real HTTP requests.
1883    struct MockAiClient;
1884
1885    impl AiClient for MockAiClient {
1886        fn send_request<'a>(
1887            &'a self,
1888            _system_prompt: &'a str,
1889            _user_prompt: &'a str,
1890        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1891            Box::pin(async { Ok(String::new()) })
1892        }
1893
1894        fn get_metadata(&self) -> AiClientMetadata {
1895            AiClientMetadata {
1896                provider: "Mock".to_string(),
1897                model: "mock-model".to_string(),
1898                max_context_length: 200_000,
1899                max_response_length: 8_192,
1900                active_beta: None,
1901            }
1902        }
1903    }
1904
1905    fn make_client() -> ClaudeClient {
1906        ClaudeClient::new(Box::new(MockAiClient))
1907    }
1908
1909    /// Mock AI client that records both prompts and per-call options
1910    /// (the schema attached, if any). Used to verify
1911    /// [`ClaudeClient::send_with_optional_schema`] dispatches via the
1912    /// options-aware method when a schema is provided and via the plain
1913    /// method otherwise.
1914    ///
1915    /// Returns the configured `response` string from both `send_request`
1916    /// and `send_request_with_options` so tests that need a parseable
1917    /// response (e.g. the refine_* coherence paths) can plug in canned
1918    /// YAML/JSON.
1919    struct SchemaRecordingMockAiClient {
1920        capabilities: AiClientCapabilities,
1921        response: String,
1922        recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1923        recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1924    }
1925    impl SchemaRecordingMockAiClient {
1926        fn new(supports_response_schema: bool) -> Self {
1927            Self::with_response(supports_response_schema, String::new())
1928        }
1929
1930        fn with_response(supports_response_schema: bool, response: String) -> Self {
1931            Self {
1932                capabilities: AiClientCapabilities {
1933                    supports_response_schema,
1934                },
1935                response,
1936                recorded_options: Arc::new(Mutex::new(Vec::new())),
1937                recorded_plain: Arc::new(Mutex::new(Vec::new())),
1938            }
1939        }
1940    }
1941
1942    impl AiClient for SchemaRecordingMockAiClient {
1943        fn send_request<'a>(
1944            &'a self,
1945            system_prompt: &'a str,
1946            user_prompt: &'a str,
1947        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1948            let plain = self.recorded_plain.clone();
1949            let sys = system_prompt.to_string();
1950            let usr = user_prompt.to_string();
1951            let response = self.response.clone();
1952            Box::pin(async move {
1953                plain.lock().unwrap().push((sys, usr));
1954                Ok(response)
1955            })
1956        }
1957
1958        fn capabilities(&self) -> AiClientCapabilities {
1959            self.capabilities
1960        }
1961
1962        fn send_request_with_options<'a>(
1963            &'a self,
1964            _system_prompt: &'a str,
1965            _user_prompt: &'a str,
1966            options: RequestOptions,
1967        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1968            let recorded = self.recorded_options.clone();
1969            let response = self.response.clone();
1970            Box::pin(async move {
1971                recorded.lock().unwrap().push(options);
1972                Ok(response)
1973            })
1974        }
1975
1976        fn get_metadata(&self) -> AiClientMetadata {
1977            AiClientMetadata {
1978                provider: "SchemaMock".to_string(),
1979                model: "schema-mock".to_string(),
1980                max_context_length: 200_000,
1981                max_response_length: 8_192,
1982                active_beta: None,
1983            }
1984        }
1985    }
1986
1987    // ── ClaudeClient schema-routing helpers ───────────────────────────
1988
1989    /// Backends that don't advertise schema support take the
1990    /// `send_request` branch in `send_with_optional_schema` regardless
1991    /// of whether a schema was supplied at the call site.
1992    #[tokio::test]
1993    async fn send_with_optional_schema_without_caps_uses_plain_send() {
1994        let inner = SchemaRecordingMockAiClient::new(false);
1995        let plain_log = inner.recorded_plain.clone();
1996        let opts_log = inner.recorded_options.clone();
1997        let client = ClaudeClient::new(Box::new(inner));
1998
1999        let schema = serde_json::json!({"type": "object"});
2000        client
2001            .send_with_optional_schema(
2002                "sys",
2003                "usr",
2004                client.schema_if_supported(&schema), // → None
2005            )
2006            .await
2007            .unwrap();
2008
2009        assert_eq!(plain_log.lock().unwrap().len(), 1);
2010        assert!(opts_log.lock().unwrap().is_empty());
2011    }
2012
2013    /// Backends that advertise schema support take the
2014    /// `send_request_with_options` branch and receive the schema in the
2015    /// options struct.
2016    #[tokio::test]
2017    async fn send_with_optional_schema_with_caps_uses_options_send() {
2018        let inner = SchemaRecordingMockAiClient::new(true);
2019        let plain_log = inner.recorded_plain.clone();
2020        let opts_log = inner.recorded_options.clone();
2021        let client = ClaudeClient::new(Box::new(inner));
2022
2023        let schema = serde_json::json!({"type": "object", "additionalProperties": false});
2024        client
2025            .send_with_optional_schema(
2026                "sys",
2027                "usr",
2028                client.schema_if_supported(&schema), // → Some
2029            )
2030            .await
2031            .unwrap();
2032
2033        let recorded = opts_log.lock().unwrap();
2034        assert_eq!(recorded.len(), 1);
2035        assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
2036        assert!(plain_log.lock().unwrap().is_empty());
2037    }
2038
2039    /// `adjusted_system_prompt` only appends the JSON-schema override
2040    /// suffix when the active backend supports schema enforcement.
2041    #[test]
2042    fn adjusted_system_prompt_adds_suffix_when_supported() {
2043        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2044        let result = client.adjusted_system_prompt("body".to_string());
2045        assert!(result.starts_with("body"));
2046        assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
2047    }
2048
2049    #[test]
2050    fn adjusted_system_prompt_passes_through_when_not_supported() {
2051        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2052        let result = client.adjusted_system_prompt("body".to_string());
2053        assert_eq!(result, "body");
2054    }
2055
2056    #[test]
2057    fn schema_if_supported_returns_some_when_supported() {
2058        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2059        let schema = serde_json::json!({"type": "object"});
2060        let returned = client.schema_if_supported(&schema);
2061        assert!(returned.is_some());
2062        assert!(std::ptr::eq(
2063            std::ptr::from_ref(returned.unwrap()),
2064            std::ptr::addr_of!(schema)
2065        ));
2066    }
2067
2068    #[test]
2069    fn schema_if_supported_returns_none_when_not_supported() {
2070        let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2071        let schema = serde_json::json!({"type": "object"});
2072        assert!(client.schema_if_supported(&schema).is_none());
2073    }
2074
2075    // ── refine_amendments_coherence / refine_checks_coherence ────────
2076
2077    /// Exercises the full body of `refine_amendments_coherence`:
2078    /// adjusted_system_prompt → validate_prompt_budget → schema-aware
2079    /// dispatch → parse_amendment_response. Uses a schema-supporting
2080    /// mock so the schema attachment branch is taken too.
2081    #[tokio::test]
2082    async fn refine_amendments_coherence_round_trip() {
2083        let mock = SchemaRecordingMockAiClient::with_response(
2084            true, // supports_response_schema
2085            "amendments: []".to_string(),
2086        );
2087        let recorded_opts = mock.recorded_options.clone();
2088        let client = ClaudeClient::new(Box::new(mock));
2089
2090        let amendment = crate::data::amendments::Amendment {
2091            commit: "abc123".to_string(),
2092            message: "feat: do thing".to_string(),
2093            summary: "did the thing".to_string(),
2094        };
2095        let items = vec![(amendment, "summary text".to_string())];
2096
2097        let result = client
2098            .refine_amendments_coherence(&items)
2099            .await
2100            .expect("coherence refinement should succeed");
2101        assert!(result.amendments.is_empty());
2102
2103        // Verify the schema-aware dispatch path was taken and that the
2104        // attached schema is the AmendmentFile schema.
2105        let recorded = recorded_opts.lock().unwrap();
2106        assert_eq!(recorded.len(), 1);
2107        let attached = recorded[0]
2108            .response_schema
2109            .as_ref()
2110            .expect("schema must be attached when capability is true");
2111        assert_eq!(
2112            attached,
2113            response_schema::amendment_file_schema(),
2114            "refine_amendments_coherence should attach the AmendmentFile schema"
2115        );
2116    }
2117
2118    /// Same coverage shape as the amendment variant, but for the check
2119    /// coherence path. Uses `parse_check_response` which needs a
2120    /// repository view to map commit hashes back to messages — we
2121    /// supply an empty view.
2122    #[tokio::test]
2123    async fn refine_checks_coherence_round_trip() {
2124        let mock = SchemaRecordingMockAiClient::with_response(
2125            true, // supports_response_schema
2126            "checks: []".to_string(),
2127        );
2128        let recorded_opts = mock.recorded_options.clone();
2129        let client = ClaudeClient::new(Box::new(mock));
2130
2131        let check = crate::data::check::CommitCheckResult {
2132            hash: "abc123".to_string(),
2133            message: "feat: do thing".to_string(),
2134            issues: Vec::new(),
2135            suggestion: None,
2136            passes: true,
2137            summary: Some("summary".to_string()),
2138        };
2139        let items = vec![(check, "summary text".to_string())];
2140        let dir = tempfile::TempDir::new().unwrap();
2141        let repo_view = make_test_repo_view(&dir);
2142
2143        let result = client
2144            .refine_checks_coherence(&items, &repo_view)
2145            .await
2146            .expect("coherence refinement should succeed");
2147        assert_eq!(result.summary.total_commits, 0);
2148
2149        let recorded = recorded_opts.lock().unwrap();
2150        assert_eq!(recorded.len(), 1);
2151        let attached = recorded[0]
2152            .response_schema
2153            .as_ref()
2154            .expect("schema must be attached when capability is true");
2155        assert_eq!(
2156            attached,
2157            response_schema::check_response_schema(),
2158            "refine_checks_coherence should attach the AiCheckResponse schema"
2159        );
2160    }
2161
2162    /// Verifies the no-schema branch of refine_amendments_coherence —
2163    /// when the backend doesn't advertise schema support, dispatch
2164    /// falls through to plain `send_request` and no schema is attached.
2165    #[tokio::test]
2166    async fn refine_amendments_coherence_without_schema_capability() {
2167        let mock = SchemaRecordingMockAiClient::with_response(
2168            false, // supports_response_schema
2169            "amendments: []".to_string(),
2170        );
2171        let recorded_plain = mock.recorded_plain.clone();
2172        let recorded_opts = mock.recorded_options.clone();
2173        let client = ClaudeClient::new(Box::new(mock));
2174
2175        let amendment = crate::data::amendments::Amendment {
2176            commit: "abc123".to_string(),
2177            message: "feat: do thing".to_string(),
2178            summary: String::new(),
2179        };
2180        let items = vec![(amendment, "summary".to_string())];
2181
2182        client
2183            .refine_amendments_coherence(&items)
2184            .await
2185            .expect("coherence refinement should succeed without schema support");
2186
2187        assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2188        assert!(
2189            recorded_opts.lock().unwrap().is_empty(),
2190            "no-schema backend must not be reached via the options path"
2191        );
2192    }
2193
2194    // ── extract_yaml_from_response ─────────────────────────────────
2195
2196    #[test]
2197    fn extract_yaml_pure_amendments() {
2198        let client = make_client();
2199        let content = "amendments:\n  - commit: abc123\n    message: test";
2200        let result = client.extract_yaml_from_response(content);
2201        assert!(result.starts_with("amendments:"));
2202    }
2203
2204    #[test]
2205    fn extract_yaml_with_markdown_yaml_block() {
2206        let client = make_client();
2207        let content = "Here is the result:\n```yaml\namendments:\n  - commit: abc\n```\n";
2208        let result = client.extract_yaml_from_response(content);
2209        assert!(result.starts_with("amendments:"));
2210    }
2211
2212    #[test]
2213    fn extract_yaml_with_generic_code_block() {
2214        let client = make_client();
2215        let content = "```\namendments:\n  - commit: abc\n```";
2216        let result = client.extract_yaml_from_response(content);
2217        assert!(result.starts_with("amendments:"));
2218    }
2219
2220    #[test]
2221    fn extract_yaml_with_whitespace() {
2222        let client = make_client();
2223        let content = "  \n  amendments:\n  - commit: abc\n  ";
2224        let result = client.extract_yaml_from_response(content);
2225        assert!(result.starts_with("amendments:"));
2226    }
2227
2228    #[test]
2229    fn extract_yaml_fallback_returns_trimmed() {
2230        let client = make_client();
2231        let content = "  some random text  ";
2232        let result = client.extract_yaml_from_response(content);
2233        assert_eq!(result, "some random text");
2234    }
2235
2236    // ── extract_yaml_from_check_response ───────────────────────────
2237
2238    #[test]
2239    fn extract_check_yaml_pure() {
2240        let client = make_client();
2241        let content = "checks:\n  - commit: abc123";
2242        let result = client.extract_yaml_from_check_response(content);
2243        assert!(result.starts_with("checks:"));
2244    }
2245
2246    #[test]
2247    fn extract_check_yaml_markdown_block() {
2248        let client = make_client();
2249        let content = "```yaml\nchecks:\n  - commit: abc\n```";
2250        let result = client.extract_yaml_from_check_response(content);
2251        assert!(result.starts_with("checks:"));
2252    }
2253
2254    #[test]
2255    fn extract_check_yaml_generic_block() {
2256        let client = make_client();
2257        let content = "```\nchecks:\n  - commit: abc\n```";
2258        let result = client.extract_yaml_from_check_response(content);
2259        assert!(result.starts_with("checks:"));
2260    }
2261
2262    #[test]
2263    fn extract_check_yaml_fallback() {
2264        let client = make_client();
2265        let content = "  unexpected content  ";
2266        let result = client.extract_yaml_from_check_response(content);
2267        assert_eq!(result, "unexpected content");
2268    }
2269
2270    // ── parse_amendment_response ────────────────────────────────────
2271
2272    #[test]
2273    fn parse_amendment_response_valid() {
2274        let client = make_client();
2275        let yaml = format!(
2276            "amendments:\n  - commit: \"{}\"\n    message: \"test message\"",
2277            "a".repeat(40)
2278        );
2279        let result = client.parse_amendment_response(&yaml);
2280        assert!(result.is_ok());
2281        assert_eq!(result.unwrap().amendments.len(), 1);
2282    }
2283
2284    #[test]
2285    fn parse_amendment_response_invalid_yaml() {
2286        let client = make_client();
2287        let result = client.parse_amendment_response("not: valid: yaml: [{{");
2288        assert!(result.is_err());
2289    }
2290
2291    #[test]
2292    fn parse_amendment_response_invalid_hash() {
2293        let client = make_client();
2294        let yaml = "amendments:\n  - commit: \"short\"\n    message: \"test\"";
2295        let result = client.parse_amendment_response(yaml);
2296        assert!(result.is_err());
2297    }
2298
2299    // ── validate_beta_header ───────────────────────────────────────
2300
2301    #[test]
2302    fn validate_beta_header_none_passes() {
2303        let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2304        assert!(result.is_ok());
2305    }
2306
2307    #[test]
2308    fn validate_beta_header_unsupported_fails() {
2309        let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2310        let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2311        assert!(result.is_err());
2312    }
2313
2314    // ── ClaudeClient::new / get_ai_client_metadata ─────────────────
2315
2316    #[test]
2317    fn client_metadata() {
2318        let client = make_client();
2319        let metadata = client.get_ai_client_metadata();
2320        assert_eq!(metadata.provider, "Mock");
2321        assert_eq!(metadata.model, "mock-model");
2322    }
2323
2324    // ── property tests ────────────────────────────────────────────
2325
2326    mod prop {
2327        use super::*;
2328        use proptest::prelude::*;
2329
2330        proptest! {
2331            #[test]
2332            fn yaml_response_output_trimmed(s in ".*") {
2333                let client = make_client();
2334                let result = client.extract_yaml_from_response(&s);
2335                prop_assert_eq!(&result, result.trim());
2336            }
2337
2338            #[test]
2339            fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2340                let client = make_client();
2341                let input = format!("amendments:{tail}");
2342                let result = client.extract_yaml_from_response(&input);
2343                prop_assert!(result.starts_with("amendments:"));
2344            }
2345
2346            #[test]
2347            fn check_response_checks_prefix_preserved(tail in ".*") {
2348                let client = make_client();
2349                let input = format!("checks:{tail}");
2350                let result = client.extract_yaml_from_check_response(&input);
2351                prop_assert!(result.starts_with("checks:"));
2352            }
2353
2354            #[test]
2355            fn yaml_fenced_block_strips_fences(
2356                content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2357            ) {
2358                let client = make_client();
2359                let input = format!("```yaml\n{content}\n```");
2360                let result = client.extract_yaml_from_response(&input);
2361                prop_assert!(!result.contains("```"));
2362            }
2363        }
2364    }
2365
2366    // ── ConfigurableMockAiClient tests ──────────────────────────────
2367
2368    fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2369        ClaudeClient::new(Box::new(
2370            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2371        ))
2372    }
2373
2374    fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2375        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2376        use crate::git::commit::FileChanges;
2377        use crate::git::{CommitAnalysis, CommitInfo};
2378
2379        let diff_path = dir.path().join("0.diff");
2380        std::fs::write(&diff_path, "+added line\n").unwrap();
2381
2382        crate::data::RepositoryView {
2383            versions: None,
2384            explanation: FieldExplanation::default(),
2385            working_directory: WorkingDirectoryInfo {
2386                clean: true,
2387                untracked_changes: Vec::new(),
2388            },
2389            remotes: Vec::new(),
2390            ai: AiInfo {
2391                scratch: String::new(),
2392            },
2393            branch_info: None,
2394            pr_template: None,
2395            pr_template_location: None,
2396            branch_prs: None,
2397            commits: vec![CommitInfo {
2398                hash: format!("{:0>40}", 0),
2399                author: "Test <test@test.com>".to_string(),
2400                date: chrono::Utc::now().fixed_offset(),
2401                original_message: "feat(test): add something".to_string(),
2402                in_main_branches: Vec::new(),
2403                analysis: CommitAnalysis {
2404                    detected_type: "feat".to_string(),
2405                    detected_scope: "test".to_string(),
2406                    proposed_message: "feat(test): add something".to_string(),
2407                    file_changes: FileChanges {
2408                        total_files: 1,
2409                        files_added: 1,
2410                        files_deleted: 0,
2411                        file_list: Vec::new(),
2412                    },
2413                    diff_summary: "file.rs | 1 +".to_string(),
2414                    diff_file: diff_path.to_string_lossy().to_string(),
2415                    file_diffs: Vec::new(),
2416                },
2417            }],
2418        }
2419    }
2420
2421    fn valid_check_yaml() -> String {
2422        format!(
2423            "checks:\n  - commit: \"{hash}\"\n    passes: true\n    issues: []\n",
2424            hash = format!("{:0>40}", 0)
2425        )
2426    }
2427
2428    #[tokio::test]
2429    async fn send_message_propagates_ai_error() {
2430        let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2431        let result = client.send_message("sys", "usr").await;
2432        assert!(result.is_err());
2433        assert!(result.unwrap_err().to_string().contains("mock error"));
2434    }
2435
2436    #[tokio::test]
2437    async fn check_commits_succeeds_after_request_error() {
2438        let dir = tempfile::tempdir().unwrap();
2439        let repo_view = make_test_repo_view(&dir);
2440        // First attempt: request error; retries return valid response.
2441        let client = make_configurable_client(vec![
2442            Err(anyhow::anyhow!("rate limit")),
2443            Ok(valid_check_yaml()),
2444            Ok(valid_check_yaml()),
2445        ]);
2446        let result = client
2447            .check_commits_with_scopes(&repo_view, None, &[], false)
2448            .await;
2449        assert!(result.is_ok());
2450    }
2451
2452    #[tokio::test]
2453    async fn check_commits_succeeds_after_parse_error() {
2454        let dir = tempfile::tempdir().unwrap();
2455        let repo_view = make_test_repo_view(&dir);
2456        // First attempt: AI returns malformed YAML; retry succeeds.
2457        let client = make_configurable_client(vec![
2458            Ok("not: valid: yaml: [[".to_string()),
2459            Ok(valid_check_yaml()),
2460            Ok(valid_check_yaml()),
2461        ]);
2462        let result = client
2463            .check_commits_with_scopes(&repo_view, None, &[], false)
2464            .await;
2465        assert!(result.is_ok());
2466    }
2467
2468    #[tokio::test]
2469    async fn check_commits_fails_after_all_retries_exhausted() {
2470        let dir = tempfile::tempdir().unwrap();
2471        let repo_view = make_test_repo_view(&dir);
2472        let client = make_configurable_client(vec![
2473            Err(anyhow::anyhow!("first failure")),
2474            Err(anyhow::anyhow!("second failure")),
2475            Err(anyhow::anyhow!("final failure")),
2476        ]);
2477        let result = client
2478            .check_commits_with_scopes(&repo_view, None, &[], false)
2479            .await;
2480        assert!(result.is_err());
2481    }
2482
2483    #[tokio::test]
2484    async fn check_commits_fails_when_all_parses_fail() {
2485        let dir = tempfile::tempdir().unwrap();
2486        let repo_view = make_test_repo_view(&dir);
2487        let client = make_configurable_client(vec![
2488            Ok("bad yaml [[".to_string()),
2489            Ok("bad yaml [[".to_string()),
2490            Ok("bad yaml [[".to_string()),
2491        ]);
2492        let result = client
2493            .check_commits_with_scopes(&repo_view, None, &[], false)
2494            .await;
2495        assert!(result.is_err());
2496    }
2497
2498    // ── split dispatch tests ─────────────────────────────────────
2499
2500    /// Creates a mock client with a constrained context window.
2501    ///
2502    /// The window is large enough that a single-file chunk fits, but too
2503    /// small for both files together (including system prompt overhead).
2504    fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2505        // Context of 50k with more conservative token estimation (2.5 chars/token
2506        // vs 3.5) ensures per-file diffs fit in chunks without placeholders while
2507        // still being large enough to trigger split dispatch for multiple files.
2508        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2509            .with_context_length(50_000);
2510        ClaudeClient::new(Box::new(mock))
2511    }
2512
2513    /// Like [`make_small_context_client`] but also returns a handle to inspect
2514    /// how many mock responses remain unconsumed after the test runs.
2515    fn make_small_context_client_tracked(
2516        responses: Vec<Result<String>>,
2517    ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2518        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2519            .with_context_length(50_000);
2520        let handle = mock.response_handle();
2521        (ClaudeClient::new(Box::new(mock)), handle)
2522    }
2523
2524    /// Creates a repo view with per-file diffs large enough to exceed the
2525    /// constrained context window, ensuring the split dispatch path triggers.
2526    fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2527        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2528        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2529        use crate::git::{CommitAnalysis, CommitInfo};
2530
2531        let hash = "a".repeat(40);
2532
2533        // Write a full (flat) diff file large enough to bust the budget.
2534        // With 50k context / 2.5 chars-per-token / 1.2 margin, available ≈ 41k tokens.
2535        // 120k chars → ~57,600 tokens → well over budget.
2536        let full_diff = "x".repeat(120_000);
2537        let flat_diff_path = dir.path().join("full.diff");
2538        std::fs::write(&flat_diff_path, &full_diff).unwrap();
2539
2540        // Write two large per-file diff files (~30K chars each ≈ 14,400 tokens with
2541        // conservative 2.5 chars/token * 1.2 margin estimation)
2542        let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2543        let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2544
2545        let path_a = dir.path().join("0000.diff");
2546        let path_b = dir.path().join("0001.diff");
2547        std::fs::write(&path_a, &diff_a).unwrap();
2548        std::fs::write(&path_b, &diff_b).unwrap();
2549
2550        crate::data::RepositoryView {
2551            versions: None,
2552            explanation: FieldExplanation::default(),
2553            working_directory: WorkingDirectoryInfo {
2554                clean: true,
2555                untracked_changes: Vec::new(),
2556            },
2557            remotes: Vec::new(),
2558            ai: AiInfo {
2559                scratch: String::new(),
2560            },
2561            branch_info: None,
2562            pr_template: None,
2563            pr_template_location: None,
2564            branch_prs: None,
2565            commits: vec![CommitInfo {
2566                hash,
2567                author: "Test <test@test.com>".to_string(),
2568                date: chrono::Utc::now().fixed_offset(),
2569                original_message: "feat(test): large commit".to_string(),
2570                in_main_branches: Vec::new(),
2571                analysis: CommitAnalysis {
2572                    detected_type: "feat".to_string(),
2573                    detected_scope: "test".to_string(),
2574                    proposed_message: "feat(test): large commit".to_string(),
2575                    file_changes: FileChanges {
2576                        total_files: 2,
2577                        files_added: 2,
2578                        files_deleted: 0,
2579                        file_list: vec![
2580                            FileChange {
2581                                status: "A".to_string(),
2582                                file: "src/a.rs".to_string(),
2583                            },
2584                            FileChange {
2585                                status: "A".to_string(),
2586                                file: "src/b.rs".to_string(),
2587                            },
2588                        ],
2589                    },
2590                    diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2591                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2592                    file_diffs: vec![
2593                        FileDiffRef {
2594                            path: "src/a.rs".to_string(),
2595                            diff_file: path_a.to_string_lossy().to_string(),
2596                            byte_len: diff_a.len(),
2597                        },
2598                        FileDiffRef {
2599                            path: "src/b.rs".to_string(),
2600                            diff_file: path_b.to_string_lossy().to_string(),
2601                            byte_len: diff_b.len(),
2602                        },
2603                    ],
2604                },
2605            }],
2606        }
2607    }
2608
2609    fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2610        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"{message}\"")
2611    }
2612
2613    #[tokio::test]
2614    async fn generate_amendments_split_dispatch() {
2615        let dir = tempfile::tempdir().unwrap();
2616        let repo_view = make_large_diff_repo_view(&dir);
2617        let hash = "a".repeat(40);
2618
2619        // Responses: chunk 1 + chunk 2 + merge pass
2620        let client = make_small_context_client(vec![
2621            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2622            Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2623            Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2624        ]);
2625
2626        let result = client
2627            .generate_amendments_with_options(&repo_view, false)
2628            .await;
2629
2630        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2631        let amendments = result.unwrap();
2632        assert_eq!(amendments.amendments.len(), 1);
2633        assert_eq!(amendments.amendments[0].commit, hash);
2634        assert!(amendments.amendments[0]
2635            .message
2636            .contains("add a.rs and b.rs"));
2637    }
2638
2639    #[tokio::test]
2640    async fn generate_amendments_split_chunk_failure() {
2641        let dir = tempfile::tempdir().unwrap();
2642        let repo_view = make_large_diff_repo_view(&dir);
2643        let hash = "a".repeat(40);
2644
2645        // First chunk succeeds, second chunk fails
2646        let client = make_small_context_client(vec![
2647            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2648            Err(anyhow::anyhow!("rate limit exceeded")),
2649        ]);
2650
2651        let result = client
2652            .generate_amendments_with_options(&repo_view, false)
2653            .await;
2654
2655        assert!(result.is_err());
2656    }
2657
2658    #[tokio::test]
2659    async fn generate_amendments_no_split_when_fits() {
2660        let dir = tempfile::tempdir().unwrap();
2661        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2662        let hash = format!("{:0>40}", 0);
2663
2664        // Only one response needed — no split dispatch
2665        let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2666            &hash,
2667            "feat(test): improved message",
2668        ))]);
2669
2670        let result = client
2671            .generate_amendments_with_options(&repo_view, false)
2672            .await;
2673
2674        assert!(result.is_ok());
2675        assert_eq!(result.unwrap().amendments.len(), 1);
2676    }
2677
2678    // ── check split dispatch tests ──────────────────────────────
2679
2680    fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2681        format!(
2682            "checks:\n  - commit: \"{hash}\"\n    passes: {passes}\n    issues: []\n    summary: \"test summary\"\n"
2683        )
2684    }
2685
2686    fn valid_check_yaml_with_issues(hash: &str) -> String {
2687        format!(
2688            concat!(
2689                "checks:\n",
2690                "  - commit: \"{hash}\"\n",
2691                "    passes: false\n",
2692                "    issues:\n",
2693                "      - severity: error\n",
2694                "        section: \"Subject Line\"\n",
2695                "        rule: \"imperative-mood\"\n",
2696                "        explanation: \"Subject uses past tense\"\n",
2697                "    suggestion:\n",
2698                "      message: \"feat(test): shorter subject\"\n",
2699                "      explanation: \"Shortened subject line\"\n",
2700                "    summary: \"Large commit with issues\"\n",
2701            ),
2702            hash = hash,
2703        )
2704    }
2705
2706    fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2707        format!(
2708            concat!(
2709                "checks:\n",
2710                "  - commit: \"{hash}\"\n",
2711                "    passes: true\n",
2712                "    issues: []\n",
2713                "    summary: \"chunk summary\"\n",
2714            ),
2715            hash = hash,
2716        )
2717    }
2718
2719    #[tokio::test]
2720    async fn check_commits_split_dispatch() {
2721        let dir = tempfile::tempdir().unwrap();
2722        let repo_view = make_large_diff_repo_view(&dir);
2723        let hash = "a".repeat(40);
2724
2725        // Responses: chunk 1 (issues + suggestion) + chunk 2 (issues + suggestion) + merge pass
2726        let client = make_small_context_client(vec![
2727            Ok(valid_check_yaml_with_issues(&hash)),
2728            Ok(valid_check_yaml_with_issues(&hash)),
2729            Ok(valid_check_yaml_with_issues(&hash)), // merge pass response
2730        ]);
2731
2732        let result = client
2733            .check_commits_with_scopes(&repo_view, None, &[], true)
2734            .await;
2735
2736        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2737        let report = result.unwrap();
2738        assert_eq!(report.commits.len(), 1);
2739        assert!(!report.commits[0].passes);
2740        // Dedup: both chunks report the same (rule, severity, section), so only 1 unique issue
2741        assert_eq!(report.commits[0].issues.len(), 1);
2742        assert_eq!(report.commits[0].issues[0].rule, "imperative-mood");
2743    }
2744
2745    #[tokio::test]
2746    async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2747        let dir = tempfile::tempdir().unwrap();
2748        let repo_view = make_large_diff_repo_view(&dir);
2749        let hash = "a".repeat(40);
2750
2751        // Responses: chunk 1 + chunk 2, both passing with no suggestions
2752        // No merge pass needed — only 2 responses
2753        let client = make_small_context_client(vec![
2754            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2755            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2756        ]);
2757
2758        let result = client
2759            .check_commits_with_scopes(&repo_view, None, &[], false)
2760            .await;
2761
2762        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2763        let report = result.unwrap();
2764        assert_eq!(report.commits.len(), 1);
2765        assert!(report.commits[0].passes);
2766        assert!(report.commits[0].issues.is_empty());
2767        assert!(report.commits[0].suggestion.is_none());
2768        // First non-None summary from chunks
2769        assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2770    }
2771
2772    #[tokio::test]
2773    async fn check_commits_split_chunk_failure() {
2774        let dir = tempfile::tempdir().unwrap();
2775        let repo_view = make_large_diff_repo_view(&dir);
2776        let hash = "a".repeat(40);
2777
2778        // First chunk succeeds, second chunk fails
2779        let client = make_small_context_client(vec![
2780            Ok(valid_check_yaml_for(&hash, true)),
2781            Err(anyhow::anyhow!("rate limit exceeded")),
2782        ]);
2783
2784        let result = client
2785            .check_commits_with_scopes(&repo_view, None, &[], false)
2786            .await;
2787
2788        assert!(result.is_err());
2789    }
2790
2791    #[tokio::test]
2792    async fn check_commits_no_split_when_fits() {
2793        let dir = tempfile::tempdir().unwrap();
2794        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2795        let hash = format!("{:0>40}", 0);
2796
2797        // Only one response needed — no split dispatch
2798        let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2799
2800        let result = client
2801            .check_commits_with_scopes(&repo_view, None, &[], false)
2802            .await;
2803
2804        assert!(result.is_ok());
2805        assert_eq!(result.unwrap().commits.len(), 1);
2806    }
2807
2808    #[tokio::test]
2809    async fn check_commits_split_dedup_across_chunks() {
2810        let dir = tempfile::tempdir().unwrap();
2811        let repo_view = make_large_diff_repo_view(&dir);
2812        let hash = "a".repeat(40);
2813
2814        // Chunk 1: two issues (error + warning)
2815        let chunk1 = format!(
2816            concat!(
2817                "checks:\n",
2818                "  - commit: \"{hash}\"\n",
2819                "    passes: false\n",
2820                "    issues:\n",
2821                "      - severity: error\n",
2822                "        section: \"Subject Line\"\n",
2823                "        rule: \"imperative-mood\"\n",
2824                "        explanation: \"Subject uses past tense\"\n",
2825                "      - severity: warning\n",
2826                "        section: \"Content\"\n",
2827                "        rule: \"body-required\"\n",
2828                "        explanation: \"Large change needs body\"\n",
2829            ),
2830            hash = hash,
2831        );
2832
2833        // Chunk 2: same error (different wording) + new info issue
2834        let chunk2 = format!(
2835            concat!(
2836                "checks:\n",
2837                "  - commit: \"{hash}\"\n",
2838                "    passes: false\n",
2839                "    issues:\n",
2840                "      - severity: error\n",
2841                "        section: \"Subject Line\"\n",
2842                "        rule: \"imperative-mood\"\n",
2843                "        explanation: \"Subject line is too long\"\n",
2844                "      - severity: info\n",
2845                "        section: \"Style\"\n",
2846                "        rule: \"scope-suggestion\"\n",
2847                "        explanation: \"Consider more specific scope\"\n",
2848            ),
2849            hash = hash,
2850        );
2851
2852        // No suggestions → no merge pass needed
2853        let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2854
2855        let result = client
2856            .check_commits_with_scopes(&repo_view, None, &[], false)
2857            .await;
2858
2859        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2860        let report = result.unwrap();
2861        assert_eq!(report.commits.len(), 1);
2862        assert!(!report.commits[0].passes);
2863        // 3 unique issues: imperative-mood, body-required, scope-suggestion
2864        // (imperative-mood appears in both chunks but deduped)
2865        assert_eq!(report.commits[0].issues.len(), 3);
2866    }
2867
2868    #[tokio::test]
2869    async fn check_commits_split_passes_only_when_all_chunks_pass() {
2870        let dir = tempfile::tempdir().unwrap();
2871        let repo_view = make_large_diff_repo_view(&dir);
2872        let hash = "a".repeat(40);
2873
2874        // Chunk 1 passes, chunk 2 fails
2875        let client = make_small_context_client(vec![
2876            Ok(valid_check_yaml_for(&hash, true)),
2877            Ok(valid_check_yaml_for(&hash, false)),
2878        ]);
2879
2880        let result = client
2881            .check_commits_with_scopes(&repo_view, None, &[], false)
2882            .await;
2883
2884        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2885        let report = result.unwrap();
2886        assert!(
2887            !report.commits[0].passes,
2888            "should fail when any chunk fails"
2889        );
2890    }
2891
2892    // ── multi-commit and PR generation paths ──────────────────────
2893
2894    /// Creates a repo view with two small commits (fits budget without split dispatch).
2895    fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2896        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2897        use crate::git::commit::FileChanges;
2898        use crate::git::{CommitAnalysis, CommitInfo};
2899
2900        let diff_a = dir.path().join("0.diff");
2901        let diff_b = dir.path().join("1.diff");
2902        std::fs::write(&diff_a, "+line a\n").unwrap();
2903        std::fs::write(&diff_b, "+line b\n").unwrap();
2904
2905        let hash_a = "a".repeat(40);
2906        let hash_b = "b".repeat(40);
2907
2908        crate::data::RepositoryView {
2909            versions: None,
2910            explanation: FieldExplanation::default(),
2911            working_directory: WorkingDirectoryInfo {
2912                clean: true,
2913                untracked_changes: Vec::new(),
2914            },
2915            remotes: Vec::new(),
2916            ai: AiInfo {
2917                scratch: String::new(),
2918            },
2919            branch_info: None,
2920            pr_template: None,
2921            pr_template_location: None,
2922            branch_prs: None,
2923            commits: vec![
2924                CommitInfo {
2925                    hash: hash_a,
2926                    author: "Test <test@test.com>".to_string(),
2927                    date: chrono::Utc::now().fixed_offset(),
2928                    original_message: "feat(a): add a".to_string(),
2929                    in_main_branches: Vec::new(),
2930                    analysis: CommitAnalysis {
2931                        detected_type: "feat".to_string(),
2932                        detected_scope: "a".to_string(),
2933                        proposed_message: "feat(a): add a".to_string(),
2934                        file_changes: FileChanges {
2935                            total_files: 1,
2936                            files_added: 1,
2937                            files_deleted: 0,
2938                            file_list: Vec::new(),
2939                        },
2940                        diff_summary: "a.rs | 1 +".to_string(),
2941                        diff_file: diff_a.to_string_lossy().to_string(),
2942                        file_diffs: Vec::new(),
2943                    },
2944                },
2945                CommitInfo {
2946                    hash: hash_b,
2947                    author: "Test <test@test.com>".to_string(),
2948                    date: chrono::Utc::now().fixed_offset(),
2949                    original_message: "feat(b): add b".to_string(),
2950                    in_main_branches: Vec::new(),
2951                    analysis: CommitAnalysis {
2952                        detected_type: "feat".to_string(),
2953                        detected_scope: "b".to_string(),
2954                        proposed_message: "feat(b): add b".to_string(),
2955                        file_changes: FileChanges {
2956                            total_files: 1,
2957                            files_added: 1,
2958                            files_deleted: 0,
2959                            file_list: Vec::new(),
2960                        },
2961                        diff_summary: "b.rs | 1 +".to_string(),
2962                        diff_file: diff_b.to_string_lossy().to_string(),
2963                        file_diffs: Vec::new(),
2964                    },
2965                },
2966            ],
2967        }
2968    }
2969
2970    #[tokio::test]
2971    async fn generate_amendments_multi_commit() {
2972        let dir = tempfile::tempdir().unwrap();
2973        let repo_view = make_multi_commit_repo_view(&dir);
2974        let hash_a = "a".repeat(40);
2975        let hash_b = "b".repeat(40);
2976
2977        let response = format!(
2978            concat!(
2979                "amendments:\n",
2980                "  - commit: \"{hash_a}\"\n",
2981                "    message: \"feat(a): improved a\"\n",
2982                "  - commit: \"{hash_b}\"\n",
2983                "    message: \"feat(b): improved b\"\n",
2984            ),
2985            hash_a = hash_a,
2986            hash_b = hash_b,
2987        );
2988        let client = make_configurable_client(vec![Ok(response)]);
2989
2990        let result = client
2991            .generate_amendments_with_options(&repo_view, false)
2992            .await;
2993
2994        assert!(
2995            result.is_ok(),
2996            "multi-commit amendment failed: {:?}",
2997            result.err()
2998        );
2999        let amendments = result.unwrap();
3000        assert_eq!(amendments.amendments.len(), 2);
3001    }
3002
3003    #[tokio::test]
3004    async fn generate_contextual_amendments_multi_commit() {
3005        let dir = tempfile::tempdir().unwrap();
3006        let repo_view = make_multi_commit_repo_view(&dir);
3007        let hash_a = "a".repeat(40);
3008        let hash_b = "b".repeat(40);
3009
3010        let response = format!(
3011            concat!(
3012                "amendments:\n",
3013                "  - commit: \"{hash_a}\"\n",
3014                "    message: \"feat(a): improved a\"\n",
3015                "  - commit: \"{hash_b}\"\n",
3016                "    message: \"feat(b): improved b\"\n",
3017            ),
3018            hash_a = hash_a,
3019            hash_b = hash_b,
3020        );
3021        let client = make_configurable_client(vec![Ok(response)]);
3022        let context = crate::data::context::CommitContext::default();
3023
3024        let result = client
3025            .generate_contextual_amendments_with_options(&repo_view, &context, false)
3026            .await;
3027
3028        assert!(
3029            result.is_ok(),
3030            "multi-commit contextual amendment failed: {:?}",
3031            result.err()
3032        );
3033        let amendments = result.unwrap();
3034        assert_eq!(amendments.amendments.len(), 2);
3035    }
3036
3037    #[tokio::test]
3038    async fn generate_pr_content_succeeds() {
3039        let dir = tempfile::tempdir().unwrap();
3040        let repo_view = make_test_repo_view(&dir);
3041
3042        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3043        let client = make_configurable_client(vec![Ok(response.to_string())]);
3044
3045        let result = client.generate_pr_content(&repo_view, "").await;
3046
3047        assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
3048        let pr = result.unwrap();
3049        assert_eq!(pr.title, "feat: add something");
3050        assert_eq!(pr.description, "Adds a new feature.");
3051    }
3052
3053    #[tokio::test]
3054    async fn generate_pr_content_with_context_succeeds() {
3055        let dir = tempfile::tempdir().unwrap();
3056        let repo_view = make_test_repo_view(&dir);
3057        let context = crate::data::context::CommitContext::default();
3058
3059        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3060        let client = make_configurable_client(vec![Ok(response.to_string())]);
3061
3062        let result = client
3063            .generate_pr_content_with_context(&repo_view, "", &context)
3064            .await;
3065
3066        assert!(
3067            result.is_ok(),
3068            "PR generation with context failed: {:?}",
3069            result.err()
3070        );
3071        let pr = result.unwrap();
3072        assert_eq!(pr.title, "feat: add something");
3073    }
3074
3075    #[tokio::test]
3076    async fn check_commits_multi_commit() {
3077        let dir = tempfile::tempdir().unwrap();
3078        let repo_view = make_multi_commit_repo_view(&dir);
3079        let hash_a = "a".repeat(40);
3080        let hash_b = "b".repeat(40);
3081
3082        let response = format!(
3083            concat!(
3084                "checks:\n",
3085                "  - commit: \"{hash_a}\"\n",
3086                "    passes: true\n",
3087                "    issues: []\n",
3088                "  - commit: \"{hash_b}\"\n",
3089                "    passes: true\n",
3090                "    issues: []\n",
3091            ),
3092            hash_a = hash_a,
3093            hash_b = hash_b,
3094        );
3095        let client = make_configurable_client(vec![Ok(response)]);
3096
3097        let result = client
3098            .check_commits_with_scopes(&repo_view, None, &[], false)
3099            .await;
3100
3101        assert!(
3102            result.is_ok(),
3103            "multi-commit check failed: {:?}",
3104            result.err()
3105        );
3106        let report = result.unwrap();
3107        assert_eq!(report.commits.len(), 2);
3108        assert!(report.commits[0].passes);
3109        assert!(report.commits[1].passes);
3110    }
3111
3112    // ── Multi-commit split dispatch helpers ──────────────────────────
3113
3114    /// Creates a repo view with two large-diff commits whose combined view
3115    /// exceeds the constrained 25KB context window.
3116    fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
3117        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3118        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3119        use crate::git::{CommitAnalysis, CommitInfo};
3120
3121        let hash_a = "a".repeat(40);
3122        let hash_b = "b".repeat(40);
3123
3124        // Write flat diff files large enough to bust the 50K-token budget when combined.
3125        // Each 60k chars ≈ 28,800 tokens; combined ≈ 57,600 > 41,808 available.
3126        let diff_content_a = "x".repeat(60_000);
3127        let diff_content_b = "y".repeat(60_000);
3128        let flat_a = dir.path().join("flat_a.diff");
3129        let flat_b = dir.path().join("flat_b.diff");
3130        std::fs::write(&flat_a, &diff_content_a).unwrap();
3131        std::fs::write(&flat_b, &diff_content_b).unwrap();
3132
3133        // Write per-file diff files for split dispatch
3134        let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3135        let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3136        let per_file_a = dir.path().join("pf_a.diff");
3137        let per_file_b = dir.path().join("pf_b.diff");
3138        std::fs::write(&per_file_a, &file_diff_a).unwrap();
3139        std::fs::write(&per_file_b, &file_diff_b).unwrap();
3140
3141        crate::data::RepositoryView {
3142            versions: None,
3143            explanation: FieldExplanation::default(),
3144            working_directory: WorkingDirectoryInfo {
3145                clean: true,
3146                untracked_changes: Vec::new(),
3147            },
3148            remotes: Vec::new(),
3149            ai: AiInfo {
3150                scratch: String::new(),
3151            },
3152            branch_info: None,
3153            pr_template: None,
3154            pr_template_location: None,
3155            branch_prs: None,
3156            commits: vec![
3157                CommitInfo {
3158                    hash: hash_a,
3159                    author: "Test <test@test.com>".to_string(),
3160                    date: chrono::Utc::now().fixed_offset(),
3161                    original_message: "feat(a): add module a".to_string(),
3162                    in_main_branches: Vec::new(),
3163                    analysis: CommitAnalysis {
3164                        detected_type: "feat".to_string(),
3165                        detected_scope: "a".to_string(),
3166                        proposed_message: "feat(a): add module a".to_string(),
3167                        file_changes: FileChanges {
3168                            total_files: 1,
3169                            files_added: 1,
3170                            files_deleted: 0,
3171                            file_list: vec![FileChange {
3172                                status: "A".to_string(),
3173                                file: "src/a.rs".to_string(),
3174                            }],
3175                        },
3176                        diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3177                        diff_file: flat_a.to_string_lossy().to_string(),
3178                        file_diffs: vec![FileDiffRef {
3179                            path: "src/a.rs".to_string(),
3180                            diff_file: per_file_a.to_string_lossy().to_string(),
3181                            byte_len: file_diff_a.len(),
3182                        }],
3183                    },
3184                },
3185                CommitInfo {
3186                    hash: hash_b,
3187                    author: "Test <test@test.com>".to_string(),
3188                    date: chrono::Utc::now().fixed_offset(),
3189                    original_message: "feat(b): add module b".to_string(),
3190                    in_main_branches: Vec::new(),
3191                    analysis: CommitAnalysis {
3192                        detected_type: "feat".to_string(),
3193                        detected_scope: "b".to_string(),
3194                        proposed_message: "feat(b): add module b".to_string(),
3195                        file_changes: FileChanges {
3196                            total_files: 1,
3197                            files_added: 1,
3198                            files_deleted: 0,
3199                            file_list: vec![FileChange {
3200                                status: "A".to_string(),
3201                                file: "src/b.rs".to_string(),
3202                            }],
3203                        },
3204                        diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3205                        diff_file: flat_b.to_string_lossy().to_string(),
3206                        file_diffs: vec![FileDiffRef {
3207                            path: "src/b.rs".to_string(),
3208                            diff_file: per_file_b.to_string_lossy().to_string(),
3209                            byte_len: file_diff_b.len(),
3210                        }],
3211                    },
3212                },
3213            ],
3214        }
3215    }
3216
3217    fn valid_pr_yaml(title: &str, description: &str) -> String {
3218        format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3219    }
3220
3221    // ── Multi-commit amendment split dispatch tests ──────────────────
3222
3223    #[tokio::test]
3224    async fn generate_amendments_multi_commit_split_dispatch() {
3225        let dir = tempfile::tempdir().unwrap();
3226        let repo_view = make_large_multi_commit_repo_view(&dir);
3227        let hash_a = "a".repeat(40);
3228        let hash_b = "b".repeat(40);
3229
3230        // Full view exceeds budget → per-commit fallback
3231        // Each commit fits individually (1 file each) → 1 response per commit
3232        let (client, handle) = make_small_context_client_tracked(vec![
3233            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3234            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3235        ]);
3236
3237        let result = client
3238            .generate_amendments_with_options(&repo_view, false)
3239            .await;
3240
3241        assert!(
3242            result.is_ok(),
3243            "multi-commit split dispatch failed: {:?}",
3244            result.err()
3245        );
3246        let amendments = result.unwrap();
3247        assert_eq!(amendments.amendments.len(), 2);
3248        assert_eq!(amendments.amendments[0].commit, hash_a);
3249        assert_eq!(amendments.amendments[1].commit, hash_b);
3250        assert!(amendments.amendments[0].message.contains("improved a"));
3251        assert!(amendments.amendments[1].message.contains("improved b"));
3252        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3253    }
3254
3255    #[tokio::test]
3256    async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3257        let dir = tempfile::tempdir().unwrap();
3258        let repo_view = make_large_multi_commit_repo_view(&dir);
3259        let hash_a = "a".repeat(40);
3260        let hash_b = "b".repeat(40);
3261        let context = crate::data::context::CommitContext::default();
3262
3263        let (client, handle) = make_small_context_client_tracked(vec![
3264            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3265            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3266        ]);
3267
3268        let result = client
3269            .generate_contextual_amendments_with_options(&repo_view, &context, false)
3270            .await;
3271
3272        assert!(
3273            result.is_ok(),
3274            "multi-commit contextual split dispatch failed: {:?}",
3275            result.err()
3276        );
3277        let amendments = result.unwrap();
3278        assert_eq!(amendments.amendments.len(), 2);
3279        assert_eq!(amendments.amendments[0].commit, hash_a);
3280        assert_eq!(amendments.amendments[1].commit, hash_b);
3281        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3282    }
3283
3284    // ── Multi-commit check split dispatch tests ──────────────────────
3285
3286    #[tokio::test]
3287    async fn check_commits_multi_commit_split_dispatch() {
3288        let dir = tempfile::tempdir().unwrap();
3289        let repo_view = make_large_multi_commit_repo_view(&dir);
3290        let hash_a = "a".repeat(40);
3291        let hash_b = "b".repeat(40);
3292
3293        // Full view exceeds budget → per-commit fallback
3294        let (client, handle) = make_small_context_client_tracked(vec![
3295            Ok(valid_check_yaml_for(&hash_a, true)),
3296            Ok(valid_check_yaml_for(&hash_b, true)),
3297        ]);
3298
3299        let result = client
3300            .check_commits_with_scopes(&repo_view, None, &[], false)
3301            .await;
3302
3303        assert!(
3304            result.is_ok(),
3305            "multi-commit check split dispatch failed: {:?}",
3306            result.err()
3307        );
3308        let report = result.unwrap();
3309        assert_eq!(report.commits.len(), 2);
3310        assert!(report.commits[0].passes);
3311        assert!(report.commits[1].passes);
3312        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3313    }
3314
3315    // ── PR split dispatch tests ──────────────────────────────────────
3316
3317    #[tokio::test]
3318    async fn generate_pr_content_split_dispatch() {
3319        let dir = tempfile::tempdir().unwrap();
3320        let repo_view = make_large_diff_repo_view(&dir);
3321
3322        // Single large commit: full view exceeds budget → per-commit fallback
3323        // 1 commit with 2 file chunks → chunk 1 + chunk 2 + chunk merge pass
3324        // Single per-commit result → returned directly (no extra merge)
3325        let (client, handle) = make_small_context_client_tracked(vec![
3326            Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3327            Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3328            Ok(valid_pr_yaml(
3329                "feat(test): add modules",
3330                "Adds a.rs and b.rs",
3331            )),
3332        ]);
3333
3334        let result = client.generate_pr_content(&repo_view, "").await;
3335
3336        assert!(
3337            result.is_ok(),
3338            "PR split dispatch failed: {:?}",
3339            result.err()
3340        );
3341        let pr = result.unwrap();
3342        assert!(pr.title.contains("add modules"));
3343        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3344    }
3345
3346    #[tokio::test]
3347    async fn generate_pr_content_multi_commit_split_dispatch() {
3348        let dir = tempfile::tempdir().unwrap();
3349        let repo_view = make_large_multi_commit_repo_view(&dir);
3350
3351        // Full view exceeds budget → per-commit fallback
3352        // Each commit fits individually → 1 response per commit, then merge pass
3353        let (client, handle) = make_small_context_client_tracked(vec![
3354            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3355            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3356            Ok(valid_pr_yaml(
3357                "feat: add modules a and b",
3358                "Adds both modules",
3359            )),
3360        ]);
3361
3362        let result = client.generate_pr_content(&repo_view, "").await;
3363
3364        assert!(
3365            result.is_ok(),
3366            "PR multi-commit split dispatch failed: {:?}",
3367            result.err()
3368        );
3369        let pr = result.unwrap();
3370        assert!(pr.title.contains("modules"));
3371        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3372    }
3373
3374    #[tokio::test]
3375    async fn generate_pr_content_with_context_from_commits_succeeds() {
3376        let dir = tempfile::tempdir().unwrap();
3377        let repo_view = make_test_repo_view(&dir);
3378        let context = crate::data::context::CommitContext::default();
3379
3380        let response = "title: \"feat: from commits\"\ndescription: \"derived from commit\"\n";
3381        let client = make_configurable_client(vec![Ok(response.to_string())]);
3382
3383        let result = client
3384            .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3385            .await;
3386
3387        assert!(
3388            result.is_ok(),
3389            "PR from-commits generation failed: {:?}",
3390            result.err()
3391        );
3392        let pr = result.unwrap();
3393        assert_eq!(pr.title, "feat: from commits");
3394    }
3395
3396    #[tokio::test]
3397    async fn generate_pr_content_with_context_from_commits_omits_diff_in_prompt() {
3398        let dir = tempfile::tempdir().unwrap();
3399        // Write a recognisable diff payload to the diff_file so we can prove it
3400        // is NOT being read into the prompt.
3401        let diff_path = dir.path().join("recognisable.diff");
3402        std::fs::write(
3403            &diff_path,
3404            "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
3405        )
3406        .unwrap();
3407
3408        // Build a repo view whose single commit's diff_file points at the
3409        // marker file but whose commit message contains a distinct subject.
3410        let repo_view = {
3411            use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3412            use crate::git::commit::FileChanges;
3413            use crate::git::{CommitAnalysis, CommitInfo};
3414            crate::data::RepositoryView {
3415                versions: None,
3416                explanation: FieldExplanation::default(),
3417                working_directory: WorkingDirectoryInfo {
3418                    clean: true,
3419                    untracked_changes: Vec::new(),
3420                },
3421                remotes: Vec::new(),
3422                ai: AiInfo {
3423                    scratch: String::new(),
3424                },
3425                branch_info: None,
3426                pr_template: None,
3427                pr_template_location: None,
3428                branch_prs: None,
3429                commits: vec![CommitInfo {
3430                    hash: format!("{:0>40}", 0),
3431                    author: "Test <test@test.com>".to_string(),
3432                    date: chrono::Utc::now().fixed_offset(),
3433                    original_message: "feat(test): UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
3434                    in_main_branches: Vec::new(),
3435                    analysis: CommitAnalysis {
3436                        detected_type: "feat".to_string(),
3437                        detected_scope: "test".to_string(),
3438                        proposed_message: "feat(test): unique".to_string(),
3439                        file_changes: FileChanges {
3440                            total_files: 1,
3441                            files_added: 1,
3442                            files_deleted: 0,
3443                            file_list: Vec::new(),
3444                        },
3445                        diff_summary: "UNIQUE_STAT_MARKER | 1 +".to_string(),
3446                        diff_file: diff_path.to_string_lossy().to_string(),
3447                        file_diffs: Vec::new(),
3448                    },
3449                }],
3450            }
3451        };
3452        let context = crate::data::context::CommitContext::default();
3453
3454        let (client, _resp_handle, prompt_handle) =
3455            make_configurable_client_with_prompts(vec![Ok(
3456                "title: \"feat: x\"\ndescription: \"y\"\n".to_string(),
3457            )]);
3458
3459        client
3460            .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3461            .await
3462            .unwrap();
3463
3464        let prompts = prompt_handle.prompts();
3465        assert_eq!(prompts.len(), 1, "expected exactly one AI call");
3466        let (system_prompt, user_prompt) = &prompts[0];
3467
3468        // Commit narrative IS present
3469        assert!(
3470            user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
3471            "user prompt must include the commit subject"
3472        );
3473        // No diff content
3474        assert!(
3475            !user_prompt.contains("UNIQUE_DIFF_MARKER"),
3476            "user prompt must NOT include diff content: {user_prompt}"
3477        );
3478        assert!(
3479            !user_prompt.contains("diff --git"),
3480            "user prompt must NOT include diff hunks"
3481        );
3482        assert!(
3483            !user_prompt.contains("diff_content"),
3484            "user prompt must NOT include diff_content YAML field"
3485        );
3486        assert!(
3487            !user_prompt.contains("UNIQUE_STAT_MARKER"),
3488            "user prompt must NOT include diff_summary"
3489        );
3490        // System prompt references commits, not diff files
3491        assert!(
3492            !system_prompt.contains("diff files"),
3493            "system prompt must not mention diff files"
3494        );
3495    }
3496
3497    #[tokio::test]
3498    async fn generate_pr_content_with_context_default_mode_includes_diff_in_prompt() {
3499        // Regression test locking in the byte-identical-default guarantee:
3500        // when --from-commits is OFF, the prompt MUST still contain diff content.
3501        let dir = tempfile::tempdir().unwrap();
3502        let repo_view = make_test_repo_view(&dir);
3503        let context = crate::data::context::CommitContext::default();
3504
3505        let (client, _resp_handle, prompt_handle) =
3506            make_configurable_client_with_prompts(vec![Ok(
3507                "title: \"feat: x\"\ndescription: \"y\"\n".to_string(),
3508            )]);
3509
3510        client
3511            .generate_pr_content_with_context(&repo_view, "", &context)
3512            .await
3513            .unwrap();
3514
3515        let prompts = prompt_handle.prompts();
3516        assert_eq!(prompts.len(), 1);
3517        let (_system_prompt, user_prompt) = &prompts[0];
3518        assert!(
3519            user_prompt.contains("diff_content"),
3520            "default mode must still serialise diff_content into the prompt"
3521        );
3522    }
3523
3524    #[tokio::test]
3525    async fn generate_pr_content_with_context_from_commits_multi_commit_per_commit_dispatch() {
3526        // Force per-commit fallback by giving each commit a large message
3527        // so the combined view busts the small (50K) context window.
3528        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3529        use crate::git::commit::FileChanges;
3530        use crate::git::{CommitAnalysis, CommitInfo};
3531
3532        let dir = tempfile::tempdir().unwrap();
3533        let make_commit = |hash: String, marker: &str, msg_size: usize| {
3534            let diff_path = dir.path().join(format!("{}.diff", &hash[..4]));
3535            std::fs::write(&diff_path, "+x\n").unwrap();
3536            CommitInfo {
3537                hash,
3538                author: "Test <test@test.com>".to_string(),
3539                date: chrono::Utc::now().fixed_offset(),
3540                original_message: format!("feat({marker}): {}", "x".repeat(msg_size)),
3541                in_main_branches: Vec::new(),
3542                analysis: CommitAnalysis {
3543                    detected_type: "feat".to_string(),
3544                    detected_scope: marker.to_string(),
3545                    proposed_message: format!("feat({marker}): t"),
3546                    file_changes: FileChanges {
3547                        total_files: 1,
3548                        files_added: 1,
3549                        files_deleted: 0,
3550                        file_list: Vec::new(),
3551                    },
3552                    diff_summary: String::new(),
3553                    diff_file: diff_path.to_string_lossy().to_string(),
3554                    file_diffs: Vec::new(),
3555                },
3556            }
3557        };
3558
3559        // Two commits, each with a ~80KB message → combined ~160KB chars,
3560        // exceeds the 50K-context-length mock's ~20K-token input budget.
3561        let repo_view = crate::data::RepositoryView {
3562            versions: None,
3563            explanation: FieldExplanation::default(),
3564            working_directory: WorkingDirectoryInfo {
3565                clean: true,
3566                untracked_changes: Vec::new(),
3567            },
3568            remotes: Vec::new(),
3569            ai: AiInfo {
3570                scratch: String::new(),
3571            },
3572            branch_info: None,
3573            pr_template: None,
3574            pr_template_location: None,
3575            branch_prs: None,
3576            commits: vec![
3577                make_commit("a".repeat(40), "a", 80_000),
3578                make_commit("b".repeat(40), "b", 80_000),
3579            ],
3580        };
3581        let context = crate::data::context::CommitContext::default();
3582
3583        // Full view exceeds → per-commit fallback (2 calls) → merge (1 call).
3584        let (client, handle) = make_small_context_client_tracked(vec![
3585            Ok(valid_pr_yaml("feat(a): a", "did a")),
3586            Ok(valid_pr_yaml("feat(b): b", "did b")),
3587            Ok(valid_pr_yaml("feat: a and b", "did both")),
3588        ]);
3589
3590        let result = client
3591            .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3592            .await;
3593
3594        assert!(
3595            result.is_ok(),
3596            "from-commits per-commit dispatch failed: {:?}",
3597            result.err()
3598        );
3599        let pr = result.unwrap();
3600        assert!(
3601            pr.title.contains("and"),
3602            "unexpected merged title: {}",
3603            pr.title
3604        );
3605        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3606    }
3607
3608    #[tokio::test]
3609    async fn generate_pr_content_with_context_from_commits_single_commit_per_commit_return() {
3610        // Force per-commit fallback with a single commit whose message busts
3611        // the budget on the full view but fits in the slimmer single-commit
3612        // view (no envelope around it). Exercises the
3613        // `per_commit_contents.len() == 1` return branch — no merge pass.
3614        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3615        use crate::git::commit::FileChanges;
3616        use crate::git::{CommitAnalysis, CommitInfo};
3617
3618        let dir = tempfile::tempdir().unwrap();
3619        let diff_path = dir.path().join("0.diff");
3620        std::fs::write(&diff_path, "+x\n").unwrap();
3621        let commit = CommitInfo {
3622            hash: "a".repeat(40),
3623            author: "Test <test@test.com>".to_string(),
3624            date: chrono::Utc::now().fixed_offset(),
3625            // ~80KB message → busts the 50K-context client's input budget
3626            // when wrapped in the full envelope, but the slimmer single-commit
3627            // view (envelope stripped) still fits with the small context.
3628            original_message: format!("feat(only): {}", "x".repeat(80_000)),
3629            in_main_branches: Vec::new(),
3630            analysis: CommitAnalysis {
3631                detected_type: "feat".to_string(),
3632                detected_scope: "only".to_string(),
3633                proposed_message: "feat(only): m".to_string(),
3634                file_changes: FileChanges {
3635                    total_files: 1,
3636                    files_added: 1,
3637                    files_deleted: 0,
3638                    file_list: Vec::new(),
3639                },
3640                diff_summary: String::new(),
3641                diff_file: diff_path.to_string_lossy().to_string(),
3642                file_diffs: Vec::new(),
3643            },
3644        };
3645        let repo_view = crate::data::RepositoryView {
3646            versions: None,
3647            explanation: FieldExplanation::default(),
3648            working_directory: WorkingDirectoryInfo {
3649                clean: true,
3650                untracked_changes: Vec::new(),
3651            },
3652            remotes: Vec::new(),
3653            ai: AiInfo {
3654                scratch: String::new(),
3655            },
3656            branch_info: None,
3657            pr_template: None,
3658            pr_template_location: None,
3659            branch_prs: None,
3660            commits: vec![commit],
3661        };
3662        let context = crate::data::context::CommitContext::default();
3663
3664        // Full envelope view exceeds → per-commit dispatch (1 call) → single
3665        // result returned directly, no merge.
3666        let (client, handle) = make_small_context_client_tracked(vec![Ok(valid_pr_yaml(
3667            "feat(only): direct return",
3668            "single commit body",
3669        ))]);
3670
3671        let result = client
3672            .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3673            .await;
3674
3675        assert!(
3676            result.is_ok(),
3677            "from-commits single-commit per-commit dispatch failed: {:?}",
3678            result.err()
3679        );
3680        let pr = result.unwrap();
3681        assert_eq!(pr.title, "feat(only): direct return");
3682        assert_eq!(
3683            handle.remaining(),
3684            0,
3685            "exactly one response should be consumed (no merge call)"
3686        );
3687    }
3688
3689    #[tokio::test]
3690    async fn generate_pr_content_with_context_from_commits_bails_on_oversized_single_commit() {
3691        // A single commit whose own slim view still exceeds the budget triggers
3692        // the bail in `generate_pr_content_for_commit_from_commits`.
3693        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3694        use crate::git::commit::FileChanges;
3695        use crate::git::{CommitAnalysis, CommitInfo};
3696
3697        let dir = tempfile::tempdir().unwrap();
3698        let diff_path = dir.path().join("0.diff");
3699        std::fs::write(&diff_path, "+x\n").unwrap();
3700        let commit = CommitInfo {
3701            hash: "c".repeat(40),
3702            author: "Test <test@test.com>".to_string(),
3703            date: chrono::Utc::now().fixed_offset(),
3704            // Message large enough that even the single-commit slim view
3705            // overflows the 50K context window's input budget.
3706            original_message: format!("feat: {}", "z".repeat(200_000)),
3707            in_main_branches: Vec::new(),
3708            analysis: CommitAnalysis {
3709                detected_type: "feat".to_string(),
3710                detected_scope: String::new(),
3711                proposed_message: "feat: oversized".to_string(),
3712                file_changes: FileChanges {
3713                    total_files: 0,
3714                    files_added: 0,
3715                    files_deleted: 0,
3716                    file_list: Vec::new(),
3717                },
3718                diff_summary: String::new(),
3719                diff_file: diff_path.to_string_lossy().to_string(),
3720                file_diffs: Vec::new(),
3721            },
3722        };
3723        let repo_view = crate::data::RepositoryView {
3724            versions: None,
3725            explanation: FieldExplanation::default(),
3726            working_directory: WorkingDirectoryInfo {
3727                clean: true,
3728                untracked_changes: Vec::new(),
3729            },
3730            remotes: Vec::new(),
3731            ai: AiInfo {
3732                scratch: String::new(),
3733            },
3734            branch_info: None,
3735            pr_template: None,
3736            pr_template_location: None,
3737            branch_prs: None,
3738            commits: vec![commit],
3739        };
3740        let context = crate::data::context::CommitContext::default();
3741
3742        // No mock responses are needed; the call should bail before making
3743        // any AI request.
3744        let client = make_small_context_client(Vec::new());
3745
3746        let result = client
3747            .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3748            .await;
3749
3750        assert!(result.is_err(), "expected bail on oversized single commit");
3751        let msg = format!("{:#}", result.unwrap_err());
3752        assert!(
3753            msg.contains("Token budget exceeded"),
3754            "expected token-budget error message, got: {msg}"
3755        );
3756        assert!(
3757            msg.contains("--from-commits"),
3758            "error should reference the from-commits mode"
3759        );
3760    }
3761
3762    #[tokio::test]
3763    async fn generate_pr_content_with_context_split_dispatch() {
3764        let dir = tempfile::tempdir().unwrap();
3765        let repo_view = make_large_multi_commit_repo_view(&dir);
3766        let context = crate::data::context::CommitContext::default();
3767
3768        // Full view exceeds budget → per-commit fallback → merge pass
3769        let (client, handle) = make_small_context_client_tracked(vec![
3770            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3771            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3772            Ok(valid_pr_yaml(
3773                "feat: add modules a and b",
3774                "Adds both modules",
3775            )),
3776        ]);
3777
3778        let result = client
3779            .generate_pr_content_with_context(&repo_view, "", &context)
3780            .await;
3781
3782        assert!(
3783            result.is_ok(),
3784            "PR with context split dispatch failed: {:?}",
3785            result.err()
3786        );
3787        let pr = result.unwrap();
3788        assert!(pr.title.contains("modules"));
3789        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3790    }
3791
3792    // ── prompt-recording split dispatch tests ────────────────────
3793
3794    /// Like [`make_small_context_client_tracked`] but also returns a
3795    /// [`PromptRecordHandle`] for inspecting which prompts were sent.
3796    fn make_small_context_client_with_prompts(
3797        responses: Vec<Result<String>>,
3798    ) -> (
3799        ClaudeClient,
3800        crate::claude::test_utils::ResponseQueueHandle,
3801        crate::claude::test_utils::PromptRecordHandle,
3802    ) {
3803        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3804            .with_context_length(50_000);
3805        let response_handle = mock.response_handle();
3806        let prompt_handle = mock.prompt_handle();
3807        (
3808            ClaudeClient::new(Box::new(mock)),
3809            response_handle,
3810            prompt_handle,
3811        )
3812    }
3813
3814    /// Creates a default-context mock client that also records prompts.
3815    fn make_configurable_client_with_prompts(
3816        responses: Vec<Result<String>>,
3817    ) -> (
3818        ClaudeClient,
3819        crate::claude::test_utils::ResponseQueueHandle,
3820        crate::claude::test_utils::PromptRecordHandle,
3821    ) {
3822        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3823        let response_handle = mock.response_handle();
3824        let prompt_handle = mock.prompt_handle();
3825        (
3826            ClaudeClient::new(Box::new(mock)),
3827            response_handle,
3828            prompt_handle,
3829        )
3830    }
3831
3832    /// Creates a repo view with one commit containing a single large file
3833    /// whose diff exceeds the token budget. Because the per-file diff is
3834    /// loaded as a whole (hunk-level granularity from the packer is lost
3835    /// at the dispatch layer), the split dispatch path will fail with a
3836    /// budget error. This helper exists to test that the error propagates
3837    /// cleanly rather than silently degrading.
3838    fn make_single_oversized_file_repo_view(
3839        dir: &tempfile::TempDir,
3840    ) -> crate::data::RepositoryView {
3841        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3842        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3843        use crate::git::{CommitAnalysis, CommitInfo};
3844
3845        let hash = "c".repeat(40);
3846
3847        // A single file diff large enough (~80K bytes ≈ 25K tokens) to
3848        // exceed the 25K context window budget even for a single chunk.
3849        let diff_content = format!(
3850            "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3851            "x".repeat(80_000)
3852        );
3853
3854        let flat_diff_path = dir.path().join("full.diff");
3855        std::fs::write(&flat_diff_path, &diff_content).unwrap();
3856
3857        let per_file_path = dir.path().join("0000.diff");
3858        std::fs::write(&per_file_path, &diff_content).unwrap();
3859
3860        crate::data::RepositoryView {
3861            versions: None,
3862            explanation: FieldExplanation::default(),
3863            working_directory: WorkingDirectoryInfo {
3864                clean: true,
3865                untracked_changes: Vec::new(),
3866            },
3867            remotes: Vec::new(),
3868            ai: AiInfo {
3869                scratch: String::new(),
3870            },
3871            branch_info: None,
3872            pr_template: None,
3873            pr_template_location: None,
3874            branch_prs: None,
3875            commits: vec![CommitInfo {
3876                hash,
3877                author: "Test <test@test.com>".to_string(),
3878                date: chrono::Utc::now().fixed_offset(),
3879                original_message: "feat(big): add large module".to_string(),
3880                in_main_branches: Vec::new(),
3881                analysis: CommitAnalysis {
3882                    detected_type: "feat".to_string(),
3883                    detected_scope: "big".to_string(),
3884                    proposed_message: "feat(big): add large module".to_string(),
3885                    file_changes: FileChanges {
3886                        total_files: 1,
3887                        files_added: 1,
3888                        files_deleted: 0,
3889                        file_list: vec![FileChange {
3890                            status: "A".to_string(),
3891                            file: "src/big.rs".to_string(),
3892                        }],
3893                    },
3894                    diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3895                    diff_file: flat_diff_path.to_string_lossy().to_string(),
3896                    file_diffs: vec![FileDiffRef {
3897                        path: "src/big.rs".to_string(),
3898                        diff_file: per_file_path.to_string_lossy().to_string(),
3899                        byte_len: diff_content.len(),
3900                    }],
3901                },
3902            }],
3903        }
3904    }
3905
3906    /// A small single-file commit whose diff fits within the token budget.
3907    ///
3908    /// Exercises the non-split path: `generate_amendments_with_options` →
3909    /// `try_full_diff_budget` succeeds → single AI request → amendment
3910    /// returned directly. Verifies exactly one request is made and the
3911    /// user prompt contains the actual diff content.
3912    #[tokio::test]
3913    async fn amendment_single_file_under_budget_no_split() {
3914        let dir = tempfile::tempdir().unwrap();
3915        let repo_view = make_test_repo_view(&dir);
3916        let hash = format!("{:0>40}", 0);
3917
3918        let (client, response_handle, prompt_handle) =
3919            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3920                &hash,
3921                "feat(test): improved message",
3922            ))]);
3923
3924        let result = client
3925            .generate_amendments_with_options(&repo_view, false)
3926            .await;
3927
3928        assert!(result.is_ok());
3929        assert_eq!(result.unwrap().amendments.len(), 1);
3930        assert_eq!(response_handle.remaining(), 0);
3931
3932        let prompts = prompt_handle.prompts();
3933        assert_eq!(
3934            prompts.len(),
3935            1,
3936            "expected exactly one AI request, no split"
3937        );
3938
3939        let (_, user_prompt) = &prompts[0];
3940        assert!(
3941            user_prompt.contains("added line"),
3942            "user prompt should contain the diff content"
3943        );
3944    }
3945
3946    /// A two-file commit that exceeds the token budget when combined.
3947    ///
3948    /// Exercises the file-level split path: `generate_amendments_with_options`
3949    /// → `try_full_diff_budget` fails → `generate_amendment_for_commit` →
3950    /// `try_full_diff_budget` fails again → `generate_amendment_split` →
3951    /// `pack_file_diffs` creates 2 chunks (one file each) → 2 AI requests
3952    /// → `merge_amendment_chunks` reduce pass → 1 merged amendment.
3953    ///
3954    /// Verifies that each chunk's user prompt contains only its file's diff
3955    /// content, and the merge prompt contains both partial amendment messages.
3956    #[tokio::test]
3957    async fn amendment_two_chunks_prompt_content() {
3958        let dir = tempfile::tempdir().unwrap();
3959        let repo_view = make_large_diff_repo_view(&dir);
3960        let hash = "a".repeat(40);
3961
3962        let (client, response_handle, prompt_handle) =
3963            make_small_context_client_with_prompts(vec![
3964                Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3965                Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3966                Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3967            ]);
3968
3969        let result = client
3970            .generate_amendments_with_options(&repo_view, false)
3971            .await;
3972
3973        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3974        let amendments = result.unwrap();
3975        assert_eq!(amendments.amendments.len(), 1);
3976        assert!(amendments.amendments[0]
3977            .message
3978            .contains("add a.rs and b.rs"));
3979        assert_eq!(response_handle.remaining(), 0);
3980
3981        let prompts = prompt_handle.prompts();
3982        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3983
3984        // Chunk 1 should contain file-a diff content (repeated 'a' chars)
3985        let (_, chunk1_user) = &prompts[0];
3986        assert!(
3987            chunk1_user.contains("aaa"),
3988            "chunk 1 prompt should contain file-a diff content"
3989        );
3990
3991        // Chunk 2 should contain file-b diff content (repeated 'b' chars)
3992        let (_, chunk2_user) = &prompts[1];
3993        assert!(
3994            chunk2_user.contains("bbb"),
3995            "chunk 2 prompt should contain file-b diff content"
3996        );
3997
3998        // Merge pass: system prompt is the synthesis prompt
3999        let (merge_sys, merge_user) = &prompts[2];
4000        assert!(
4001            merge_sys.contains("synthesiz"),
4002            "merge system prompt should contain synthesis instructions"
4003        );
4004        // Merge user prompt should contain both partial messages
4005        assert!(
4006            merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
4007            "merge user prompt should contain both partial amendment messages"
4008        );
4009    }
4010
4011    /// A single file whose diff exceeds the budget even after split dispatch.
4012    ///
4013    /// Exercises the budget-error path: `generate_amendment_for_commit` →
4014    /// budget exceeded → `generate_amendment_split` → `pack_file_diffs`
4015    /// plans hunk-level chunks → but `from_commit_info_partial` loads the
4016    /// full per-file diff (deduplicates the repeated path) →
4017    /// Oversized files that can't be split get placeholders and proceed.
4018    ///
4019    /// Verifies that files too large for the budget are replaced with
4020    /// placeholder text indicating the file was omitted, rather than
4021    /// failing with a "prompt too large" error.
4022    #[tokio::test]
4023    async fn amendment_single_oversized_file_gets_placeholder() {
4024        let dir = tempfile::tempdir().unwrap();
4025        let repo_view = make_single_oversized_file_repo_view(&dir);
4026        let hash = "c".repeat(40);
4027
4028        // The file is too large for the full budget but gets a placeholder.
4029        // With 50k context, the placeholder is small enough to fit in a
4030        // single request. Provide a second response in case the system prompt
4031        // is large enough to trigger split dispatch.
4032        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4033            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4034            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4035        ]);
4036
4037        let result = client
4038            .generate_amendments_with_options(&repo_view, false)
4039            .await;
4040
4041        // Should succeed (either single request or split with placeholder)
4042        assert!(
4043            result.is_ok(),
4044            "expected success with placeholder, got: {result:?}"
4045        );
4046
4047        // One request (placeholder makes it fit in single request)
4048        assert!(
4049            prompt_handle.request_count() >= 1,
4050            "expected at least 1 request, got {}",
4051            prompt_handle.request_count()
4052        );
4053    }
4054
4055    /// A two-chunk split where the second chunk's AI request fails.
4056    ///
4057    /// Exercises the error-propagation path within `generate_amendment_split`:
4058    /// chunk 1 succeeds → chunk 2 returns `Err` → the `?` operator in the
4059    /// loop body propagates the error immediately, skipping the merge pass.
4060    ///
4061    /// Verifies that exactly 2 requests are recorded (no further processing)
4062    /// and the overall result is `Err` (no silent degradation).
4063    #[tokio::test]
4064    async fn amendment_chunk_failure_stops_dispatch() {
4065        let dir = tempfile::tempdir().unwrap();
4066        let repo_view = make_large_diff_repo_view(&dir);
4067        let hash = "a".repeat(40);
4068
4069        // First chunk succeeds, second chunk fails
4070        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4071            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
4072            Err(anyhow::anyhow!("rate limit exceeded")),
4073        ]);
4074
4075        let result = client
4076            .generate_amendments_with_options(&repo_view, false)
4077            .await;
4078
4079        assert!(result.is_err());
4080
4081        // Exactly 2 requests: chunk 1 (success) + chunk 2 (failure)
4082        let prompts = prompt_handle.prompts();
4083        assert_eq!(
4084            prompts.len(),
4085            2,
4086            "should stop after the failing chunk, got {} requests",
4087            prompts.len()
4088        );
4089
4090        // The first request should reference one of the files
4091        let (_, first_user) = &prompts[0];
4092        assert!(
4093            first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
4094            "first chunk prompt should reference a file"
4095        );
4096    }
4097
4098    /// Two-chunk amendment split dispatch, focused on the reduce pass inputs.
4099    ///
4100    /// Exercises `merge_amendment_chunks` which calls
4101    /// `generate_chunk_merge_user_prompt` to assemble the merge prompt from:
4102    /// the commit hash, original message, diff_summary, and the partial
4103    /// amendment messages returned by each chunk.
4104    ///
4105    /// Verifies that the merge (3rd) request's user prompt contains all of:
4106    /// both partial messages, the original commit message, the diff_summary
4107    /// file paths, and the commit hash.
4108    #[tokio::test]
4109    async fn amendment_reduce_pass_prompt_content() {
4110        let dir = tempfile::tempdir().unwrap();
4111        let repo_view = make_large_diff_repo_view(&dir);
4112        let hash = "a".repeat(40);
4113
4114        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4115            Ok(valid_amendment_yaml(
4116                &hash,
4117                "feat(a): add module a implementation",
4118            )),
4119            Ok(valid_amendment_yaml(
4120                &hash,
4121                "feat(b): add module b implementation",
4122            )),
4123            Ok(valid_amendment_yaml(
4124                &hash,
4125                "feat(test): add modules a and b",
4126            )),
4127        ]);
4128
4129        let result = client
4130            .generate_amendments_with_options(&repo_view, false)
4131            .await;
4132
4133        assert!(result.is_ok());
4134
4135        let prompts = prompt_handle.prompts();
4136        assert_eq!(prompts.len(), 3);
4137
4138        // The merge pass is the last (3rd) request
4139        let (merge_system, merge_user) = &prompts[2];
4140
4141        // System prompt should be the amendment chunk merge prompt
4142        assert!(
4143            merge_system.contains("synthesiz"),
4144            "merge system prompt should contain synthesis instructions"
4145        );
4146
4147        // User prompt should contain the partial messages from chunks
4148        assert!(
4149            merge_user.contains("feat(a): add module a implementation"),
4150            "merge user prompt should contain chunk 1's partial message"
4151        );
4152        assert!(
4153            merge_user.contains("feat(b): add module b implementation"),
4154            "merge user prompt should contain chunk 2's partial message"
4155        );
4156
4157        // User prompt should contain the original commit message
4158        assert!(
4159            merge_user.contains("feat(test): large commit"),
4160            "merge user prompt should contain the original commit message"
4161        );
4162
4163        // User prompt should contain the diff_summary referencing both files
4164        assert!(
4165            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4166            "merge user prompt should contain the diff_summary"
4167        );
4168
4169        // User prompt should reference the commit hash
4170        assert!(
4171            merge_user.contains(&hash),
4172            "merge user prompt should reference the commit hash"
4173        );
4174    }
4175
4176    /// Two-chunk check split dispatch with issue deduplication and merge.
4177    ///
4178    /// Exercises `check_commit_split` which:
4179    /// 1. Dispatches 2 chunk requests (one per file)
4180    /// 2. Collects issues from both chunks into a `HashSet` keyed by
4181    ///    `(rule, severity, section)` — duplicates are dropped
4182    /// 3. Detects that both chunks have suggestions → calls
4183    ///    `merge_check_chunks` for the AI reduce pass
4184    ///
4185    /// Chunk 1 reports: `error:imperative-mood:Subject Line` +
4186    ///                   `warning:body-required:Content`
4187    /// Chunk 2 reports: `error:imperative-mood:Subject Line` (duplicate) +
4188    ///                   `info:scope-suggestion:Style` (new)
4189    ///
4190    /// Verifies: 3 unique issues after dedup, suggestion from merge pass,
4191    /// and the merge prompt contains both partial suggestions + diff_summary.
4192    #[tokio::test]
4193    async fn check_split_dedup_and_merge_prompt() {
4194        let dir = tempfile::tempdir().unwrap();
4195        let repo_view = make_large_diff_repo_view(&dir);
4196        let hash = "a".repeat(40);
4197
4198        // Chunk 1: error (imperative-mood) + warning (body-required) + suggestion
4199        let chunk1_yaml = format!(
4200            concat!(
4201                "checks:\n",
4202                "  - commit: \"{hash}\"\n",
4203                "    passes: false\n",
4204                "    issues:\n",
4205                "      - severity: error\n",
4206                "        section: \"Subject Line\"\n",
4207                "        rule: \"imperative-mood\"\n",
4208                "        explanation: \"Subject uses past tense\"\n",
4209                "      - severity: warning\n",
4210                "        section: \"Content\"\n",
4211                "        rule: \"body-required\"\n",
4212                "        explanation: \"Large change needs body\"\n",
4213                "    suggestion:\n",
4214                "      message: \"feat(a): shorter subject for a\"\n",
4215                "      explanation: \"Shortened subject for file a\"\n",
4216                "    summary: \"Adds module a\"\n",
4217            ),
4218            hash = hash,
4219        );
4220
4221        // Chunk 2: same error (different explanation) + new info issue + suggestion
4222        let chunk2_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 line is way too long\"\n",
4232                "      - severity: info\n",
4233                "        section: \"Style\"\n",
4234                "        rule: \"scope-suggestion\"\n",
4235                "        explanation: \"Consider more specific scope\"\n",
4236                "    suggestion:\n",
4237                "      message: \"feat(b): shorter subject for b\"\n",
4238                "      explanation: \"Shortened subject for file b\"\n",
4239                "    summary: \"Adds module b\"\n",
4240            ),
4241            hash = hash,
4242        );
4243
4244        // Merge pass (called because suggestions exist)
4245        let merge_yaml = format!(
4246            concat!(
4247                "checks:\n",
4248                "  - commit: \"{hash}\"\n",
4249                "    passes: false\n",
4250                "    issues: []\n",
4251                "    suggestion:\n",
4252                "      message: \"feat(test): add modules a and b\"\n",
4253                "      explanation: \"Combined suggestion\"\n",
4254                "    summary: \"Adds modules a and b\"\n",
4255            ),
4256            hash = hash,
4257        );
4258
4259        let (client, response_handle, prompt_handle) =
4260            make_small_context_client_with_prompts(vec![
4261                Ok(chunk1_yaml),
4262                Ok(chunk2_yaml),
4263                Ok(merge_yaml),
4264            ]);
4265
4266        let result = client
4267            .check_commits_with_scopes(&repo_view, None, &[], true)
4268            .await;
4269
4270        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
4271        let report = result.unwrap();
4272        assert_eq!(report.commits.len(), 1);
4273        assert!(!report.commits[0].passes);
4274        assert_eq!(response_handle.remaining(), 0);
4275
4276        // Dedup: 3 unique (rule, severity, section) tuples
4277        //  - imperative-mood / error / Subject Line   (appears in both → deduped)
4278        //  - body-required    / warning / Content
4279        //  - scope-suggestion / info / Style
4280        assert_eq!(
4281            report.commits[0].issues.len(),
4282            3,
4283            "expected 3 unique issues after dedup, got {:?}",
4284            report.commits[0]
4285                .issues
4286                .iter()
4287                .map(|i| &i.rule)
4288                .collect::<Vec<_>>()
4289        );
4290
4291        // Suggestion should come from the merge pass
4292        assert!(report.commits[0].suggestion.is_some());
4293        assert!(
4294            report.commits[0]
4295                .suggestion
4296                .as_ref()
4297                .unwrap()
4298                .message
4299                .contains("add modules a and b"),
4300            "suggestion should come from the merge pass"
4301        );
4302
4303        // Prompt content assertions
4304        let prompts = prompt_handle.prompts();
4305        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
4306
4307        // Chunk prompts should collectively cover both files
4308        let (_, chunk1_user) = &prompts[0];
4309        let (_, chunk2_user) = &prompts[1];
4310        let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
4311        assert!(
4312            combined_chunk_prompts.contains("src/a.rs")
4313                && combined_chunk_prompts.contains("src/b.rs"),
4314            "chunk prompts should collectively cover both files"
4315        );
4316
4317        // Merge pass prompt should contain partial suggestions
4318        let (merge_sys, merge_user) = &prompts[2];
4319        assert!(
4320            merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
4321            "merge system prompt should be the check chunk merge prompt"
4322        );
4323        assert!(
4324            merge_user.contains("feat(a): shorter subject for a")
4325                && merge_user.contains("feat(b): shorter subject for b"),
4326            "merge user prompt should contain both partial suggestions"
4327        );
4328        // Merge prompt should contain the diff_summary
4329        assert!(
4330            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4331            "merge user prompt should contain the diff_summary"
4332        );
4333    }
4334
4335    // ── Amendment retry tests ──────────────────────────────────────────
4336
4337    #[tokio::test]
4338    async fn amendment_retry_parse_failure_then_success() {
4339        let dir = tempfile::tempdir().unwrap();
4340        let repo_view = make_test_repo_view(&dir);
4341        let hash = format!("{:0>40}", 0);
4342
4343        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4344            Ok("not valid yaml {{[".to_string()),
4345            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4346        ]);
4347
4348        let result = client
4349            .generate_amendments_with_options(&repo_view, false)
4350            .await;
4351
4352        assert!(
4353            result.is_ok(),
4354            "should succeed after retry: {:?}",
4355            result.err()
4356        );
4357        assert_eq!(result.unwrap().amendments.len(), 1);
4358        assert_eq!(response_handle.remaining(), 0, "both responses consumed");
4359        assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
4360    }
4361
4362    #[tokio::test]
4363    async fn amendment_retry_request_failure_then_success() {
4364        let dir = tempfile::tempdir().unwrap();
4365        let repo_view = make_test_repo_view(&dir);
4366        let hash = format!("{:0>40}", 0);
4367
4368        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4369            Err(anyhow::anyhow!("rate limit")),
4370            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4371        ]);
4372
4373        let result = client
4374            .generate_amendments_with_options(&repo_view, false)
4375            .await;
4376
4377        assert!(
4378            result.is_ok(),
4379            "should succeed after retry: {:?}",
4380            result.err()
4381        );
4382        assert_eq!(result.unwrap().amendments.len(), 1);
4383        assert_eq!(response_handle.remaining(), 0);
4384        assert_eq!(prompt_handle.request_count(), 2);
4385    }
4386
4387    #[tokio::test]
4388    async fn amendment_retry_all_attempts_exhausted() {
4389        let dir = tempfile::tempdir().unwrap();
4390        let repo_view = make_test_repo_view(&dir);
4391
4392        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4393            Ok("bad yaml 1".to_string()),
4394            Ok("bad yaml 2".to_string()),
4395            Ok("bad yaml 3".to_string()),
4396        ]);
4397
4398        let result = client
4399            .generate_amendments_with_options(&repo_view, false)
4400            .await;
4401
4402        assert!(result.is_err(), "should fail after all retries exhausted");
4403        assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
4404        assert_eq!(
4405            prompt_handle.request_count(),
4406            3,
4407            "exactly 3 AI requests (1 + 2 retries)"
4408        );
4409    }
4410
4411    #[tokio::test]
4412    async fn amendment_retry_success_first_attempt() {
4413        let dir = tempfile::tempdir().unwrap();
4414        let repo_view = make_test_repo_view(&dir);
4415        let hash = format!("{:0>40}", 0);
4416
4417        let (client, response_handle, prompt_handle) =
4418            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
4419                &hash,
4420                "feat(test): works first time",
4421            ))]);
4422
4423        let result = client
4424            .generate_amendments_with_options(&repo_view, false)
4425            .await;
4426
4427        assert!(result.is_ok());
4428        assert_eq!(response_handle.remaining(), 0);
4429        assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
4430    }
4431
4432    #[tokio::test]
4433    async fn amendment_retry_mixed_request_and_parse_failures() {
4434        let dir = tempfile::tempdir().unwrap();
4435        let repo_view = make_test_repo_view(&dir);
4436        let hash = format!("{:0>40}", 0);
4437
4438        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4439            Err(anyhow::anyhow!("network error")),
4440            Ok("invalid yaml {{".to_string()),
4441            Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
4442        ]);
4443
4444        let result = client
4445            .generate_amendments_with_options(&repo_view, false)
4446            .await;
4447
4448        assert!(
4449            result.is_ok(),
4450            "should succeed on third attempt: {:?}",
4451            result.err()
4452        );
4453        assert_eq!(result.unwrap().amendments.len(), 1);
4454        assert_eq!(response_handle.remaining(), 0);
4455        assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
4456    }
4457
4458    // ── create_default_claude_client factory ───────────────────────
4459    //
4460    // Backend dispatch is the env-parsing boundary; tests inject a pure
4461    // `MapEnv` into `create_default_claude_client_with` rather than mutating
4462    // the process environment, so they need no lock and run in parallel.
4463
4464    use crate::test_support::env::MapEnv;
4465
4466    #[tokio::test]
4467    async fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
4468        let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude-cli");
4469
4470        let client = create_default_claude_client_with(&env, None, None)
4471            .await
4472            .expect("factory should succeed");
4473        let metadata = client.get_ai_client_metadata();
4474        assert_eq!(metadata.provider, "Claude CLI");
4475        // Default model falls through to the registry's claude default.
4476        assert_eq!(metadata.model, "claude-sonnet-4-6");
4477    }
4478
4479    #[tokio::test]
4480    async fn factory_claude_cli_backend_honours_model_precedence() {
4481        // CLAUDE_MODEL has higher precedence than CLAUDE_CODE_MODEL.
4482        let env = MapEnv::new()
4483            .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4484            .with("CLAUDE_CODE_MODEL", "opus")
4485            .with("CLAUDE_MODEL", "haiku");
4486
4487        let client = create_default_claude_client_with(&env, None, None)
4488            .await
4489            .expect("factory should succeed");
4490        let metadata = client.get_ai_client_metadata();
4491        assert_eq!(metadata.provider, "Claude CLI");
4492        assert_eq!(metadata.model, "haiku");
4493    }
4494
4495    #[tokio::test]
4496    async fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4497        let env = MapEnv::new()
4498            .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4499            .with("CLAUDE_MODEL", "haiku");
4500
4501        let client = create_default_claude_client_with(&env, Some("opus".to_string()), None)
4502            .await
4503            .expect("factory should succeed");
4504        let metadata = client.get_ai_client_metadata();
4505        assert_eq!(metadata.model, "opus");
4506    }
4507
4508    #[tokio::test]
4509    async fn factory_claude_cli_backend_accepts_underscore_alias() {
4510        let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude_cli");
4511
4512        let client = create_default_claude_client_with(&env, None, None)
4513            .await
4514            .expect("factory should succeed");
4515        let metadata = client.get_ai_client_metadata();
4516        assert_eq!(metadata.provider, "Claude CLI");
4517    }
4518
4519    #[tokio::test]
4520    async fn factory_claude_cli_backend_ignores_beta_header_without_validation() {
4521        // The claude-cli arm warns and drops the beta header instead of
4522        // validating it — the model may be a short alias ("sonnet") the
4523        // registry does not know, and even an unsupported header/model pair
4524        // must not error on this backend.
4525        let env = MapEnv::new()
4526            .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4527            .with("CLAUDE_MODEL", "sonnet")
4528            .with("OMNI_DEV_BETA_HEADER", "anthropic-beta:not-a-real-beta");
4529
4530        let client = create_default_claude_client_with(&env, None, None)
4531            .await
4532            .expect("claude-cli must ignore the beta header, not validate it");
4533        let metadata = client.get_ai_client_metadata();
4534        assert_eq!(metadata.provider, "Claude CLI");
4535        assert_eq!(metadata.model, "sonnet");
4536        assert_eq!(
4537            metadata.active_beta, None,
4538            "beta header must not be forwarded"
4539        );
4540    }
4541
4542    #[tokio::test]
4543    async fn factory_ollama_branch_probes_loaded_context_length() {
4544        use wiremock::matchers::{method, path};
4545        use wiremock::{Mock, MockServer, ResponseTemplate};
4546
4547        // Activate an INFO subscriber for this (current-thread) test so the
4548        // probe's success `info!` event fires in-process and its fields are
4549        // evaluated. Discard the formatted output — we assert on the probed
4550        // value below, not the log line.
4551        let _log_guard = tracing::subscriber::set_default(
4552            tracing_subscriber::fmt()
4553                .with_max_level(tracing::Level::INFO)
4554                .with_writer(std::io::sink)
4555                .finish(),
4556        );
4557
4558        let server = MockServer::start().await;
4559        Mock::given(method("GET"))
4560            .and(path("/api/v0/models"))
4561            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4562                "data": [
4563                    { "id": "lm-loaded", "state": "loaded", "loaded_context_length": 6144_u64 }
4564                ]
4565            })))
4566            .mount(&server)
4567            .await;
4568
4569        let env = MapEnv::new()
4570            .with("USE_OLLAMA", "true")
4571            .with("OLLAMA_BASE_URL", &server.uri())
4572            .with("OLLAMA_MODEL", "lm-loaded");
4573
4574        let client = create_default_claude_client_with(&env, None, None)
4575            .await
4576            .expect("factory should succeed");
4577        let metadata = client.get_ai_client_metadata();
4578        assert_eq!(metadata.provider, "Ollama");
4579        assert_eq!(metadata.model, "lm-loaded");
4580        // The probed value (6144) overrides the registry/default.
4581        assert_eq!(metadata.max_context_length, 6144);
4582    }
4583
4584    #[tokio::test]
4585    async fn factory_ollama_branch_falls_back_when_probe_fails() {
4586        use wiremock::matchers::{method, path};
4587        use wiremock::{Mock, MockServer, ResponseTemplate};
4588
4589        let server = MockServer::start().await;
4590        Mock::given(method("GET"))
4591            .and(path("/api/v0/models"))
4592            .respond_with(ResponseTemplate::new(500))
4593            .mount(&server)
4594            .await;
4595        Mock::given(method("POST"))
4596            .and(path("/api/show"))
4597            .respond_with(ResponseTemplate::new(500))
4598            .mount(&server)
4599            .await;
4600
4601        let env = MapEnv::new()
4602            .with("USE_OLLAMA", "true")
4603            .with("OLLAMA_BASE_URL", &server.uri())
4604            .with("OLLAMA_MODEL", "no-such-model");
4605
4606        let client = create_default_claude_client_with(&env, None, None)
4607            .await
4608            .expect("factory should succeed");
4609        let metadata = client.get_ai_client_metadata();
4610        // Probe failure → fall back to the registry estimate (which
4611        // resolves to FALLBACK_INPUT_CONTEXT for unknown models).
4612        let registry_value =
4613            crate::claude::model_config::get_model_registry().get_input_context("no-such-model");
4614        assert_eq!(metadata.max_context_length, registry_value);
4615    }
4616
4617    /// LM Studio path is tested above. This complements it by exercising
4618    /// the Ollama-native fallthrough through the factory, so the
4619    /// info-log arm fires for both `ProbeSource` variants.
4620    #[tokio::test]
4621    async fn factory_ollama_branch_probes_via_ollama_native() {
4622        use wiremock::matchers::{method, path};
4623        use wiremock::{Mock, MockServer, ResponseTemplate};
4624
4625        let server = MockServer::start().await;
4626        Mock::given(method("GET"))
4627            .and(path("/api/v0/models"))
4628            .respond_with(ResponseTemplate::new(404))
4629            .mount(&server)
4630            .await;
4631        Mock::given(method("POST"))
4632            .and(path("/api/show"))
4633            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4634                "model_info": { "llama.context_length": 12288_u64 }
4635            })))
4636            .mount(&server)
4637            .await;
4638
4639        let env = MapEnv::new()
4640            .with("USE_OLLAMA", "true")
4641            .with("OLLAMA_BASE_URL", &server.uri())
4642            .with("OLLAMA_MODEL", "ollama-native-model");
4643
4644        let client = create_default_claude_client_with(&env, None, None)
4645            .await
4646            .expect("factory should succeed");
4647        let metadata = client.get_ai_client_metadata();
4648        assert_eq!(metadata.max_context_length, 12288);
4649    }
4650
4651    // The OpenAI / Bedrock / default-Claude branches construct their clients
4652    // synchronously (no network probe, unlike Ollama), so a pure MapEnv drives
4653    // them with no env mutation. These cover the non-Ollama dispatch arms.
4654
4655    #[tokio::test]
4656    async fn factory_openai_branch_builds_client() {
4657        let env = MapEnv::new()
4658            .with("USE_OPENAI", "true")
4659            .with("OPENAI_MODEL", "gpt-4.1")
4660            .with("OPENAI_API_KEY", "sk-test");
4661
4662        let client = create_default_claude_client_with(&env, None, None)
4663            .await
4664            .expect("factory should succeed");
4665        assert_eq!(client.get_ai_client_metadata().model, "gpt-4.1");
4666    }
4667
4668    #[tokio::test]
4669    async fn factory_openai_branch_errors_without_api_key() {
4670        let env = MapEnv::new().with("USE_OPENAI", "true");
4671        let result = create_default_claude_client_with(&env, None, None).await;
4672        assert!(result.is_err());
4673    }
4674
4675    #[tokio::test]
4676    async fn factory_bedrock_branch_builds_client() {
4677        let env = MapEnv::new()
4678            .with("CLAUDE_CODE_USE_BEDROCK", "true")
4679            .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
4680            .with("ANTHROPIC_AUTH_TOKEN", "tok")
4681            .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
4682
4683        let client = create_default_claude_client_with(&env, None, None)
4684            .await
4685            .expect("factory should succeed");
4686        assert_eq!(client.get_ai_client_metadata().model, "claude-sonnet-4-6");
4687    }
4688
4689    #[tokio::test]
4690    async fn factory_bedrock_branch_errors_without_auth_token() {
4691        let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
4692        let result = create_default_claude_client_with(&env, None, None).await;
4693        assert!(result.is_err());
4694    }
4695
4696    #[tokio::test]
4697    async fn factory_bedrock_branch_errors_without_base_url() {
4698        let env = MapEnv::new()
4699            .with("CLAUDE_CODE_USE_BEDROCK", "true")
4700            .with("ANTHROPIC_AUTH_TOKEN", "tok");
4701        let result = create_default_claude_client_with(&env, None, None).await;
4702        assert!(result.is_err());
4703    }
4704
4705    #[tokio::test]
4706    async fn factory_default_claude_branch_builds_client() {
4707        let env = MapEnv::new()
4708            .with("ANTHROPIC_MODEL", "claude-opus-4-6")
4709            .with("CLAUDE_API_KEY", "sk-test");
4710
4711        let client = create_default_claude_client_with(&env, None, None)
4712            .await
4713            .expect("factory should succeed");
4714        assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4715    }
4716
4717    #[tokio::test]
4718    async fn factory_default_claude_branch_errors_without_api_key() {
4719        let result = create_default_claude_client_with(&MapEnv::new(), None, None).await;
4720        assert!(result.is_err());
4721    }
4722
4723    // ── shared-resolver behaviors introduced by #1118 ───────────────
4724
4725    #[tokio::test]
4726    async fn factory_default_claude_branch_honours_claude_model_chain() {
4727        // The headline #1118 bug: CLAUDE_MODEL / CLAUDE_CODE_MODEL were
4728        // silently ignored by the direct-API branch.
4729        let env = MapEnv::new()
4730            .with("CLAUDE_MODEL", "claude-opus-4-6")
4731            .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
4732            .with("CLAUDE_API_KEY", "sk-test");
4733
4734        let client = create_default_claude_client_with(&env, None, None)
4735            .await
4736            .expect("factory should succeed");
4737        assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4738    }
4739
4740    #[tokio::test]
4741    async fn factory_bedrock_branch_honours_claude_model_chain() {
4742        let env = MapEnv::new()
4743            .with("CLAUDE_CODE_USE_BEDROCK", "true")
4744            .with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
4745            .with("ANTHROPIC_AUTH_TOKEN", "tok")
4746            .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
4747
4748        let client = create_default_claude_client_with(&env, None, None)
4749            .await
4750            .expect("factory should succeed");
4751        assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4752    }
4753
4754    #[tokio::test]
4755    async fn factory_omni_dev_model_beats_provider_var() {
4756        let env = MapEnv::new()
4757            .with("USE_OPENAI", "true")
4758            .with("OMNI_DEV_MODEL", "gpt-4.1")
4759            .with("OPENAI_MODEL", "gpt-5-mini")
4760            .with("OPENAI_API_KEY", "sk-test");
4761
4762        let client = create_default_claude_client_with(&env, None, None)
4763            .await
4764            .expect("factory should succeed");
4765        assert_eq!(client.get_ai_client_metadata().model, "gpt-4.1");
4766    }
4767
4768    #[tokio::test]
4769    async fn factory_backend_env_var_beats_legacy_use_flags() {
4770        // OMNI_DEV_AI_BACKEND=openai wins even though USE_OLLAMA is set.
4771        let env = MapEnv::new()
4772            .with("OMNI_DEV_AI_BACKEND", "openai")
4773            .with("USE_OLLAMA", "true")
4774            .with("OPENAI_API_KEY", "sk-test");
4775
4776        let client = create_default_claude_client_with(&env, None, None)
4777            .await
4778            .expect("factory should succeed");
4779        assert_eq!(client.get_ai_client_metadata().model, "gpt-5-mini");
4780    }
4781
4782    #[tokio::test]
4783    async fn factory_backend_default_value_forces_direct_api() {
4784        // `--ai-backend default` propagates as OMNI_DEV_AI_BACKEND=default and
4785        // must override the USE_* soup, dispatching the direct Claude client.
4786        let env = MapEnv::new()
4787            .with("OMNI_DEV_AI_BACKEND", "default")
4788            .with("USE_OLLAMA", "true")
4789            .with("CLAUDE_API_KEY", "sk-test");
4790
4791        let client = create_default_claude_client_with(&env, None, None)
4792            .await
4793            .expect("factory should succeed");
4794        assert_eq!(client.get_ai_client_metadata().model, "claude-sonnet-4-6");
4795    }
4796
4797    #[tokio::test]
4798    async fn factory_unknown_backend_value_is_hard_error() {
4799        let env = MapEnv::new()
4800            .with("OMNI_DEV_AI_BACKEND", "junk")
4801            .with("CLAUDE_API_KEY", "sk-test");
4802
4803        let err = create_default_claude_client_with(&env, None, None)
4804            .await
4805            .map(|_| ())
4806            .expect_err("unknown backend must error");
4807        assert!(format!("{err:#}").contains("junk"));
4808    }
4809
4810    #[tokio::test]
4811    async fn factory_beta_header_env_var_is_applied() {
4812        let env = MapEnv::new()
4813            .with("ANTHROPIC_MODEL", "claude-3-7-sonnet-20250219")
4814            .with(
4815                "OMNI_DEV_BETA_HEADER",
4816                "anthropic-beta:output-128k-2025-02-19",
4817            )
4818            .with("CLAUDE_API_KEY", "sk-test");
4819
4820        let client = create_default_claude_client_with(&env, None, None)
4821            .await
4822            .expect("factory should accept a registry-supported beta header");
4823        // The beta header raises the model's max output tokens.
4824        assert_eq!(client.get_ai_client_metadata().max_response_length, 128_000);
4825    }
4826
4827    #[tokio::test]
4828    async fn factory_beta_header_env_var_malformed_is_hard_error() {
4829        let env = MapEnv::new()
4830            .with("OMNI_DEV_BETA_HEADER", "no-colon-here")
4831            .with("CLAUDE_API_KEY", "sk-test");
4832
4833        let err = create_default_claude_client_with(&env, None, None)
4834            .await
4835            .map(|_| ())
4836            .expect_err("malformed beta header must error");
4837        assert!(format!("{err:#}").contains("no-colon-here"));
4838    }
4839}