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