use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serial_test::serial;
use trusty_common::credentials::MemoryKeyStore;
use trusty_common::credentials::test_sandbox::CredentialSandbox;
use trusty_common::inference::registry::{ProviderCapabilities, ProviderId, capabilities};
use trusty_common::inference::test_support::ScriptedAdapter;
use trusty_common::inference::{
AssistantMessage, ChatChoice, ChatRequest, ChatResponse, InferenceAdapter, InferenceError,
UsageBlock,
};
use super::{
CLASSIFIER_MODEL_ENV, DEFAULT_CLASSIFIER_MODEL, OpenRouterClassifier, Source, extract_json,
parse_state, resolve_classifier_model,
};
use crate::activity::cache::ActivityState;
use crate::activity::monitor::{ActivityError, LlmClassifier};
fn caps() -> &'static ProviderCapabilities {
capabilities(ProviderId::OpenRouter)
}
fn response_with(content: &str, prompt_tokens: u32, completion_tokens: u32) -> ChatResponse {
ChatResponse {
id: "test".into(),
model: DEFAULT_CLASSIFIER_MODEL.into(),
choices: vec![ChatChoice {
message: AssistantMessage {
content: Some(content.to_owned()),
tool_calls: Vec::new(),
},
finish_reason: Some("stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
..Default::default()
},
}
}
fn classifier_returning(outcome: Result<ChatResponse, InferenceError>) -> OpenRouterClassifier {
let scripted = match outcome {
Ok(response) => ScriptedAdapter::new("scripted", caps()).with_response(response),
Err(error) => ScriptedAdapter::new("scripted", caps()).with_error(error),
};
OpenRouterClassifier::with_adapter(Arc::new(scripted), DEFAULT_CLASSIFIER_MODEL)
}
fn credentialed_over(store: MemoryKeyStore, model: &str) -> OpenRouterClassifier {
let mut configurator = trusty_common::inference::Configurator::new();
trusty_common::inference::register_default_factories(&mut configurator);
OpenRouterClassifier {
model: model.to_owned(),
source: Source::Credentialed {
configurator,
store: Box::new(store),
},
}
}
fn credential_sandbox() -> CredentialSandbox {
let mut sandbox = CredentialSandbox::enter();
sandbox.remove(CLASSIFIER_MODEL_ENV);
sandbox
}
struct RecordingAdapter {
seen: Mutex<Vec<ChatRequest>>,
reply: ChatResponse,
}
#[async_trait]
impl InferenceAdapter for RecordingAdapter {
fn name(&self) -> &str {
"recording"
}
fn capabilities(&self) -> &ProviderCapabilities {
caps()
}
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse, InferenceError> {
self.seen
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(request.clone());
Ok(self.reply.clone())
}
}
#[test]
fn extract_json_plain() {
assert_eq!(
extract_json(r#"{"state":"idle"}"#),
Some(r#"{"state":"idle"}"#)
);
}
#[test]
fn extract_json_from_fenced_prose() {
let raw = "Sure! Here you go:\n```json\n{\"state\": \"working\"}\n```\nHope that helps.";
assert_eq!(extract_json(raw), Some("{\"state\": \"working\"}"));
}
#[test]
fn extract_json_spans_to_last_brace() {
let raw = "noise {\"a\": {\"b\": 1}} trailing";
assert_eq!(extract_json(raw), Some("{\"a\": {\"b\": 1}}"));
}
#[test]
fn extract_json_none_without_braces() {
assert_eq!(extract_json("the session looks idle"), None);
assert_eq!(extract_json(""), None);
assert_eq!(extract_json("{ unterminated"), None);
}
#[test]
fn extract_json_none_when_reversed() {
assert_eq!(extract_json("} then {"), None);
assert_eq!(extract_json("{"), None);
}
#[test]
fn parse_state_maps_every_documented_state() {
assert_eq!(parse_state("working"), ActivityState::Working);
assert_eq!(parse_state("idle"), ActivityState::Idle);
assert_eq!(
parse_state("blocked_on_permission"),
ActivityState::BlockedOnPermission
);
assert_eq!(parse_state("errored"), ActivityState::Errored);
assert_eq!(parse_state("done"), ActivityState::Done);
assert_eq!(parse_state("unknown"), ActivityState::Unknown);
}
#[test]
fn parse_state_is_case_insensitive() {
assert_eq!(parse_state("WORKING"), ActivityState::Working);
assert_eq!(
parse_state("Blocked_On_Permission"),
ActivityState::BlockedOnPermission
);
}
#[test]
fn parse_state_unknown_for_garbage() {
assert_eq!(parse_state(""), ActivityState::Unknown);
assert_eq!(parse_state("busy"), ActivityState::Unknown);
assert_eq!(parse_state("not working"), ActivityState::Unknown);
}
#[test]
#[serial]
fn model_defaults_when_env_unset() {
let _sandbox = credential_sandbox();
assert_eq!(resolve_classifier_model(), DEFAULT_CLASSIFIER_MODEL);
assert_eq!(DEFAULT_CLASSIFIER_MODEL, "openai/gpt-4o-mini");
}
#[test]
#[serial]
fn model_reads_env_override() {
let mut sandbox = credential_sandbox();
sandbox.set(CLASSIFIER_MODEL_ENV, "vendor/some-model");
assert_eq!(resolve_classifier_model(), "vendor/some-model");
sandbox.set(CLASSIFIER_MODEL_ENV, " ");
assert_eq!(resolve_classifier_model(), DEFAULT_CLASSIFIER_MODEL);
}
#[tokio::test]
async fn classify_parses_verdict_and_usage() {
let classifier = classifier_returning(Ok(response_with(
r#"{"state": "blocked_on_permission", "summary": "awaiting approval", "confidence": 0.83}"#,
123,
45,
)));
let (verdict, input, output) = classifier.classify("pane").await.expect("classifies");
assert_eq!(verdict.state, ActivityState::BlockedOnPermission);
assert_eq!(verdict.summary, "awaiting approval");
assert!((verdict.confidence - 0.83).abs() < 1e-6);
assert_eq!((input, output), (123, 45));
}
#[tokio::test]
async fn classify_defaults_missing_fields() {
let classifier = classifier_returning(Ok(response_with(r#"{"summary": "just text"}"#, 1, 1)));
let (verdict, _, _) = classifier.classify("pane").await.expect("classifies");
assert_eq!(verdict.state, ActivityState::Unknown);
assert_eq!(verdict.summary, "just text");
assert!((verdict.confidence - 0.5).abs() < 1e-6);
}
#[tokio::test]
async fn classify_sends_one_user_turn() {
let recorder = Arc::new(RecordingAdapter {
seen: Mutex::new(Vec::new()),
reply: response_with(
r#"{"state": "working", "summary": "ok", "confidence": 1.0}"#,
7,
3,
),
});
let classifier = OpenRouterClassifier::with_adapter(recorder.clone(), "vendor/pinned-model");
classifier
.classify("PANE-SENTINEL-42")
.await
.expect("classifies");
let seen = recorder.seen.lock().expect("recorded");
assert_eq!(
seen.len(),
1,
"exactly one inference call per classification"
);
let request = &seen[0];
assert_eq!(request.model, "vendor/pinned-model");
assert_eq!(request.messages.len(), 1);
assert_eq!(request.messages[0].role, "user");
let content = request.messages[0]
.content
.as_deref()
.expect("user turn carries content");
assert!(
content.contains("PANE-SENTINEL-42"),
"pane text is embedded"
);
assert!(
content.contains("blocked_on_permission"),
"states are listed"
);
assert!(request.tools.is_none(), "the classifier requests no tools");
}
#[tokio::test]
#[serial]
async fn classify_missing_credential_maps_to_missing_api_key() {
let _sandbox = credential_sandbox();
let classifier = credentialed_over(MemoryKeyStore::new(), DEFAULT_CLASSIFIER_MODEL);
let err = classifier
.classify("pane")
.await
.expect_err("empty store must not resolve a provider");
assert!(matches!(err, ActivityError::MissingApiKey), "got {err:?}");
}
#[tokio::test]
async fn classify_adapter_missing_credential_maps_to_missing_api_key() {
let classifier = classifier_returning(Err(InferenceError::MissingCredential {
provider: ProviderId::OpenRouter,
}));
let err = classifier
.classify("pane")
.await
.expect_err("credential error");
assert!(matches!(err, ActivityError::MissingApiKey), "got {err:?}");
}
#[tokio::test]
async fn classify_adapter_error_maps_to_llm() {
let classifier = classifier_returning(Err(InferenceError::Api {
status: 503,
body: "upstream unavailable".into(),
}));
let err = classifier.classify("pane").await.expect_err("api error");
let ActivityError::Llm(message) = err else {
panic!("expected ActivityError::Llm, got {err:?}");
};
assert!(message.contains("503"), "status is preserved: {message}");
assert!(
message.contains("upstream unavailable"),
"body is preserved: {message}"
);
}
#[tokio::test]
async fn classify_unparseable_output_maps_to_serialization() {
let classifier = classifier_returning(Ok(response_with(
"I'm not sure what that session is doing.",
5,
9,
)));
let err = classifier.classify("pane").await.expect_err("unparseable");
let ActivityError::Serialization(message) = err else {
panic!("expected ActivityError::Serialization, got {err:?}");
};
assert!(
message.contains("I'm not sure"),
"raw model text is attached: {message}"
);
}
#[tokio::test]
async fn classify_malformed_json_maps_to_serialization() {
let classifier = classifier_returning(Ok(response_with(r#"{"state": "worki"#, 1, 0)));
let err = classifier.classify("pane").await.expect_err("malformed");
assert!(
matches!(err, ActivityError::Serialization(_)),
"got {err:?}"
);
}
#[tokio::test]
async fn classify_empty_content_maps_to_serialization() {
let mut response = response_with("", 0, 0);
response.choices[0].message.content = None;
let classifier = classifier_returning(Ok(response));
let err = classifier
.classify("pane")
.await
.expect_err("empty content");
assert!(
matches!(err, ActivityError::Serialization(_)),
"got {err:?}"
);
}