#[allow(clippy::wildcard_imports)]
use super::super::*;
use super::{finalize_call_result, roots_list_failure_is_permanent};
mod shell_outcome_tests {
use super::*;
use crate::server::tool_trait::ShellOutcome;
fn text_of(result: &CallToolResult) -> String {
result
.content
.first()
.and_then(|c| c.as_text())
.map(|t| t.text.clone())
.unwrap_or_default()
}
#[test]
fn success_exit_is_not_an_error() {
let r = finalize_call_result("ok", Some(ShellOutcome::Exit(0)));
assert_ne!(r.is_error, Some(true), "exit 0 must not set isError");
assert!(
r.structured_content.is_none(),
"happy path stays token-neutral: no structuredContent on exit 0"
);
assert_eq!(text_of(&r), "ok");
}
#[test]
fn nonzero_exit_with_output_is_not_error() {
let r = finalize_call_result("boom\n[exit:1]", Some(ShellOutcome::Exit(1)));
assert_ne!(
r.is_error,
Some(true),
"exit 1 with output must NOT set isError (#1090)"
);
assert!(
r.structured_content.is_none(),
"benign exit 1 must not shadow its output with structuredContent (#1127)"
);
assert_eq!(text_of(&r), "boom\n[exit:1]", "output text is preserved");
}
#[test]
fn stderr_only_failure_keeps_its_text() {
let text = "ls: /nonexistent-path-xyz: No such file or directory\n[exit:1]";
let r = finalize_call_result(text, Some(ShellOutcome::Exit(1)));
assert_ne!(r.is_error, Some(true));
assert!(r.structured_content.is_none());
assert_eq!(text_of(&r), text);
}
#[test]
fn timeout_with_partial_output_keeps_its_text() {
let text = "line one\nERROR: command timed out after 5000ms";
let r = finalize_call_result(text, Some(ShellOutcome::Exit(124)));
assert_ne!(r.is_error, Some(true));
assert!(r.structured_content.is_none());
assert_eq!(text_of(&r), text);
}
#[test]
fn exit_1_without_output_is_error() {
let r = finalize_call_result("[exit:1]", Some(ShellOutcome::Exit(1)));
assert_eq!(
r.is_error,
Some(true),
"exit 1 with no command output must set isError"
);
}
#[test]
fn exit_2_is_always_error() {
let r = finalize_call_result("error output", Some(ShellOutcome::Exit(2)));
assert_eq!(
r.is_error,
Some(true),
"exit >= 2 must always set isError (#389)"
);
assert_eq!(
r.structured_content,
Some(serde_json::json!({ "exitCode": 2 })),
"guards must be able to read exitCode without text parsing"
);
}
#[test]
fn negative_exit_codes_are_reported() {
let r = finalize_call_result("killed", Some(ShellOutcome::Exit(-1)));
assert_eq!(r.is_error, Some(true));
assert_eq!(
r.structured_content,
Some(serde_json::json!({ "exitCode": -1 }))
);
}
#[test]
fn blocked_command_sets_is_error_and_blocked_marker() {
let r = finalize_call_result("[BLOCKED] nope", Some(ShellOutcome::Blocked));
assert_eq!(
r.is_error,
Some(true),
"blocked commands never ran — that is a failure"
);
assert_eq!(
r.structured_content,
Some(serde_json::json!({ "blocked": true }))
);
}
#[test]
fn non_shell_tools_are_unaffected() {
let r = finalize_call_result("file contents", None);
assert_ne!(r.is_error, Some(true));
assert!(r.structured_content.is_none());
}
}
#[cfg(test)]
mod roots_retry_tests {
use super::roots_list_failure_is_permanent;
#[test]
fn method_not_found_is_permanent() {
let err = rmcp::ServiceError::McpError(rmcp::model::ErrorData::new(
rmcp::model::ErrorCode::METHOD_NOT_FOUND,
"Method not found",
None,
));
assert!(roots_list_failure_is_permanent(&err));
}
#[test]
fn timeouts_and_other_mcp_errors_are_transient() {
let timeout = rmcp::ServiceError::Timeout {
timeout: std::time::Duration::from_secs(5),
};
assert!(!roots_list_failure_is_permanent(&timeout));
let internal = rmcp::ServiceError::McpError(rmcp::model::ErrorData::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
"boom",
None,
));
assert!(!roots_list_failure_is_permanent(&internal));
}
}
#[cfg(test)]
mod response_cache_tests {
use super::super::guarded::{cache_call_result, cached_call_result, response_cache_key};
use crate::core::ocla::response_cache::{CachedResponse, ResponseCache};
use rmcp::model::{CallToolResult, ContentBlock};
use serde_json::{Map, json};
use std::time::{Duration, Instant};
fn arguments() -> Map<String, serde_json::Value> {
Map::from_iter([("path".to_owned(), json!("src/lib.rs"))])
}
fn text_of(result: &CallToolResult) -> &str {
result.content[0]
.as_text()
.expect("cached result must be text")
.text
.as_str()
}
#[test]
fn only_deterministic_tools_receive_cache_keys() {
for tool in ["ctx_read", "ctx_search", "ctx_tree", "ctx_glob"] {
assert!(response_cache_key(tool, Some(&arguments()), "/project").is_some());
}
for tool in ["ctx_shell", "ctx_session", "ctx_knowledge", "ctx_call"] {
assert!(response_cache_key(tool, Some(&arguments()), "/project").is_none());
}
}
#[test]
fn cache_keys_include_arguments_and_project_scope() {
let base = response_cache_key("ctx_read", Some(&arguments()), "/project").unwrap();
let other_root = response_cache_key("ctx_read", Some(&arguments()), "/other").unwrap();
let other_args = Map::from_iter([("path".to_owned(), json!("src/main.rs"))]);
let other_path = response_cache_key("ctx_read", Some(&other_args), "/project").unwrap();
assert_ne!(base, other_root);
assert_ne!(base, other_path);
assert_eq!(
base,
response_cache_key("ctx_read", Some(&arguments()), "/project").unwrap()
);
}
#[test]
fn successful_text_response_round_trips_through_cache() {
let cache = ResponseCache::new(8, Duration::from_mins(1));
let key = response_cache_key("ctx_search", Some(&arguments()), "/project").unwrap();
let mut result = CallToolResult::success(vec![ContentBlock::text("search result")]);
result.structured_content = Some(json!({"matches": 1}));
cache_call_result(&cache, key.clone(), &result);
let cached = cached_call_result(&cache, &key).expect("response should be cached");
assert_eq!(text_of(&cached), "search result");
assert_eq!(cached.structured_content, Some(json!({"matches": 1})));
assert_ne!(cached.is_error, Some(true));
}
#[test]
fn expired_response_is_not_returned() {
let cache = ResponseCache::new(8, Duration::from_secs(1));
let key = response_cache_key("ctx_tree", Some(&arguments()), "/project").unwrap();
cache.put(
key.clone(),
CachedResponse {
body: b"stale".to_vec(),
status: 200,
tokens: 1,
created_at: Instant::now().checked_sub(Duration::from_secs(2)).unwrap(),
ttl: Duration::from_secs(1),
},
);
assert!(cached_call_result(&cache, &key).is_none());
}
}