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