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