Skip to main content

omni_dev/claude/
client.rs

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