use crate::classify::rules::CategoryDef;
use crate::classify::tiers::llm::LlmClassifier;
use crate::classify::tiers::llm_prompt::{
resolve, restricted_system_prompt, LlmOutcome, LlmUsage, ABSTAIN_LABEL,
};
use crate::core::config::LlmEffort;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn allowed() -> Vec<CategoryDef> {
vec![
CategoryDef {
name: "bug_fix".into(),
description: Some("Corrects wrong\n behaviour.".into()),
},
CategoryDef {
name: "enablement".into(),
description: None,
},
]
}
fn verdict_json(category: &str) -> String {
format!(
"{{\"category\":\"{category}\",\"subcategory\":\"x\",\"confidence\":0.8,\"complexity\":2}}"
)
}
const USAGE: LlmUsage = LlmUsage {
input_tokens: 120,
output_tokens: 9,
};
#[test]
fn restricted_prompt_lists_only_configured_categories() {
let p = restricted_system_prompt(&allowed());
assert!(p.contains("- bug_fix: Corrects wrong behaviour.\n"), "{p}");
assert!(p.contains("- enablement\n"), "{p}");
assert!(p.contains(&format!("- {ABSTAIN_LABEL}: ")), "{p}");
assert!(p.contains("<one of: bug_fix|enablement|unclear>"), "{p}");
let offered: Vec<&str> = p.lines().filter(|l| l.starts_with("- ")).collect();
assert_eq!(offered.len(), 3, "exactly two categories plus abstain: {p}");
for builtin in ["feature|", "bugfix|", "chore", "documentation", "refactor|"] {
assert!(!p.contains(builtin), "built-in `{builtin}` leaked: {p}");
}
}
#[test]
fn out_of_set_reply_is_an_abstention() {
let cats = allowed();
let call = resolve(Some(&verdict_json("chore")), Some(&cats), Some(USAGE));
assert_eq!(call.outcome, LlmOutcome::OutOfSet);
assert!(call.verdict.is_none());
assert_eq!(call.usage, Some(USAGE), "tokens are billed either way");
}
#[test]
fn abstain_label_is_an_abstention() {
let cats = allowed();
let call = resolve(Some(&verdict_json("UNCLEAR")), Some(&cats), None);
assert_eq!(call.outcome, LlmOutcome::Abstained);
assert!(call.verdict.is_none());
let call = resolve(Some(&verdict_json("Bug_Fix")), Some(&cats), None);
assert_eq!(call.outcome, LlmOutcome::Answered);
let v = call.verdict.expect("verdict");
assert_eq!(v.category, "bug_fix");
assert_eq!(v.subcategory, None);
}
#[test]
fn unrestricted_reply_is_kept() {
let call = resolve(Some(&format!(" {} ", verdict_json("chore"))), None, None);
assert_eq!(call.outcome, LlmOutcome::Answered);
let v = call.verdict.expect("verdict");
assert_eq!(v.category, "chore");
assert_eq!(v.subcategory.as_deref(), Some("x"));
let fenced = format!("```json\n{}\n```", verdict_json("bug_fix"));
assert_eq!(
resolve(Some(&fenced), None, None).outcome,
LlmOutcome::Failed
);
let cats = allowed();
assert_eq!(
resolve(Some(&fenced), Some(&cats), None).outcome,
LlmOutcome::Answered
);
let call = resolve(Some("not json"), None, Some(USAGE));
assert_eq!(call.outcome, LlmOutcome::Failed);
assert_eq!(call.usage, Some(USAGE));
}
async fn anthropic_server(category: &str) -> MockServer {
let server = MockServer::start().await;
let body = serde_json::json!({
"content": [
{"type": "thinking", "thinking": ""},
{"type": "text", "text": verdict_json(category)}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 120, "output_tokens": 9}
});
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
server
}
fn anthropic_llm(server: &MockServer) -> LlmClassifier {
LlmClassifier::build_anthropic("claude-sonnet-5", Some("sk-ant-test".to_string())) .with_endpoint(format!("{}/v1/messages", server.uri()))
}
async fn sent_body(server: &MockServer) -> serde_json::Value {
let reqs = server.received_requests().await.expect("recording on");
serde_json::from_slice(&reqs[0].body).expect("json body")
}
#[tokio::test]
async fn classifier_sends_restricted_prompt_and_drops_out_of_set() {
let server = anthropic_server("chore").await;
let llm = anthropic_llm(&server).with_allowed_categories(allowed());
let call = llm.classify_detailed("tidy up").await;
assert_eq!(call.outcome, LlmOutcome::OutOfSet);
assert!(call.verdict.is_none());
let body = sent_body(&server).await;
let system = body["system"].as_str().expect("system");
assert!(system.contains("- enablement"), "{system}");
assert!(!system.contains("chore"), "built-in list sent: {system}");
assert!(body.get("output_config").is_none(), "no effort unless set");
}
#[tokio::test]
async fn anthropic_usage_is_recorded() {
let server = anthropic_server("feature").await;
let llm = anthropic_llm(&server).with_effort(Some(LlmEffort::Low));
let call = llm.classify_detailed("feat: add login").await;
assert_eq!(call.outcome, LlmOutcome::Answered);
assert_eq!(call.usage, Some(USAGE));
assert_eq!(sent_body(&server).await["output_config"]["effort"], "low");
assert_eq!(llm.provider_label(), "anthropic-api");
assert_eq!(llm.model(), "claude-sonnet-5");
}
#[tokio::test]
async fn non_jev_providers_receive_the_real_message() {
let server = anthropic_server("feature").await;
let message = "fix ACME-42 for jane.doe@corp.example.com in src/billing/ledger.rs";
anthropic_llm(&server).classify_detailed(message).await;
let sent = sent_body(&server).await["messages"].to_string();
assert!(sent.contains(message), "{sent}");
assert!(
!sent.contains("EMAIL_") && !sent.contains("TICKET_"),
"{sent}"
);
}
#[tokio::test]
async fn openai_usage_is_recorded() {
let server = MockServer::start().await;
let body = serde_json::json!({
"choices": [{"message": {"content": verdict_json("feature")}}],
"usage": {"prompt_tokens": 120, "completion_tokens": 9, "total_tokens": 129}
});
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let llm = LlmClassifier::new("gpt-4o-mini", Some("sk-test".to_string()))
.with_endpoint(format!("{}/v1/chat/completions", server.uri()));
let call = llm.classify_detailed("feat: add login").await;
assert_eq!(call.outcome, LlmOutcome::Answered);
assert_eq!(call.usage, Some(USAGE));
}
#[test]
fn error_body_is_truncated() {
use crate::classify::tiers::llm::truncate_chars;
assert_eq!(truncate_chars("short", 500), "short");
let long = "é".repeat(600);
let cut = truncate_chars(&long, 500);
assert_eq!(cut.chars().count(), 501);
assert!(cut.ends_with('…'));
}