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