use super::{
CallToolResult, ContentBlock, Duration, MetaObject, PreparedCallResult, ResponseCache, Value,
cache_call_result, cached_call_result, response_cache_key,
};
#[test]
fn loop_throttle_block_is_a_tool_error() {
use lean_ctx::core::config::LoopDetectionConfig;
use lean_ctx::core::loop_detection::LoopDetector;
let cfg = LoopDetectionConfig {
blocked_threshold: 2,
..Default::default()
};
let mut detector = LoopDetector::with_config(&cfg);
let first = detector.record_call("ctx_shell", "same_fp");
assert!(super::loop_throttle_refusal(&first).is_none());
detector.record_call("ctx_shell", "same_fp");
let blocked = detector.record_call("ctx_shell", "same_fp");
let result = super::loop_throttle_refusal(&blocked).expect("blocked call is refused");
assert_eq!(result.is_error, Some(true));
let text = result.content[0].as_text().expect("text").text.clone();
assert!(text.contains("LOOP DETECTED"), "{text}");
}
#[test]
fn response_cache_cannot_reuse_a_result_under_changed_rules() {
use lean_ctx::core::policy::{parse, resolve, runtime::TestPolicyOverride};
let cache = ResponseCache::new(16, Duration::from_mins(1));
let policy = |prefix: &str| {
resolve(&parse(&format!(
"name = 'cache'\nversion = '1.0.0'\ndescription = 'test'\n[redaction]\ncustomer = '{prefix}-[0-9]{{4}}'\n"
)).unwrap()).unwrap()
};
let key = {
let _policy = TestPolicyOverride::set(Some(policy("CUS")));
response_cache_key("ctx_tree", None, "/fixture").unwrap()
};
cache_call_result(
&cache,
key.clone(),
&CallToolResult::success(vec![ContentBlock::text("ACC-1234")]),
);
assert!(cached_call_result(&cache, &key).is_some());
let _policy = TestPolicyOverride::set(Some(policy("ACC")));
let changed = response_cache_key("ctx_tree", None, "/fixture").unwrap();
assert!(cached_call_result(&cache, &changed).is_none());
}
#[tokio::test]
async fn policy_revoked_after_preparation_prevents_primitive_dispatch() {
use lean_ctx::core::execution_lifecycle::{
ExecutionDriver, ExecutionLifecycle, ProductEntitlements, RuntimeContext, ToolRequest,
ToolSurface,
};
let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
let root = tempfile::tempdir().unwrap();
let policy = root.path().join(".lean-ctx/policy.toml");
std::fs::create_dir_all(policy.parent().unwrap()).unwrap();
let base = "name = 'revoke'\nversion = '1.0.0'\ndescription = 'test'\n";
std::fs::write(&policy, base).unwrap();
let source = root.path().join("sample.txt");
std::fs::write(&source, "CUS-1234").unwrap();
let server = crate::tools::LeanCtxServer::new_with_project_root(root.path().to_str());
lean_ctx::core::policy::runtime::REQUEST_PROJECT
.scope(std::cell::RefCell::new(Some(root.path().into())), async {
let request = serde_json::from_value(serde_json::json!({"name":"ctx_read",
"arguments":{"path":source,"mode":"full"}}))
.unwrap();
let prepared = server
.prepare_tool_call(
request,
super::McpEntitlements {
config: std::sync::Arc::new(lean_ctx::core::config::Config::default()),
},
)
.await
.unwrap();
assert!(matches!(prepared, PreparedCallResult::Ready(_)));
let lifecycle = ExecutionLifecycle::default();
let context = lifecycle.begin(
ToolRequest {
tool_name: "ctx_read".into(),
query: None,
session_id: "revoke".into(),
agent_id: "revoke".into(),
surface: ToolSurface::Mcp,
idempotency_key: None,
},
RuntimeContext {
project_root: root.path().to_str().map(str::to_owned),
..RuntimeContext::default()
},
ProductEntitlements {
autopilot: false,
personalized_learning: false,
},
);
let mut driver = super::McpDriver {
server: &server,
request: None,
entitlements: None,
prepared: Some(prepared),
checkpoint: None,
pending_cache_key: None,
native_receipt_capture: None,
native_receipt_metadata: None,
input_tokens: 0,
};
tokio::task::yield_now().await;
std::fs::write(
&policy,
format!("{base}[context]\ndeny_tools = ['ctx_read']\n"),
)
.unwrap();
let (primitive, _) = driver.dispatch_primitive(&context).await.unwrap();
let super::McpDispatched::Terminal(result, reason) = primitive else {
panic!("revoked request must never dispatch the primitive");
};
assert_eq!(reason, "policy changed before dispatch");
assert!(!serde_json::to_string(&result).unwrap().contains("CUS-1234"));
})
.await;
}
#[tokio::test]
async fn mcp_context_attachment_is_not_an_autopilot_decision() {
use lean_ctx::core::execution_lifecycle::{
ExecutionDriver, ExecutionLifecycle, ProductEntitlements, RuntimeContext, StageDisposition,
ToolRequest, ToolSurface,
};
let directory = tempfile::tempdir().unwrap();
let root = directory.path().to_str().unwrap();
let server = crate::tools::LeanCtxServer::new_with_project_root(Some(root));
let lifecycle = ExecutionLifecycle::default();
for enabled in [false, true] {
let context = lifecycle.begin(
ToolRequest {
tool_name: "ctx_read".to_owned(),
query: Some("inspect lifecycle".to_owned()),
session_id: "autopilot-stage-test".to_owned(),
agent_id: "autopilot-stage-test".to_owned(),
surface: ToolSurface::Mcp,
idempotency_key: None,
},
RuntimeContext {
project_root: Some(root.to_owned()),
..RuntimeContext::default()
},
ProductEntitlements {
autopilot: enabled,
personalized_learning: false,
},
);
let mut driver = super::McpDriver {
server: &server,
request: None,
entitlements: None,
prepared: Some(PreparedCallResult::Ready(Box::new(
super::PreparedMcpCall {
name: "ctx_read".to_owned(),
args: None,
minimal: true,
config: std::sync::Arc::new(lean_ctx::core::config::Config::default()),
machine_readable: false,
auto_context: None,
throttle_warning: None,
args_fp: String::new(),
decision_context: None,
kernel_handoff: Default::default(),
cache_key: None,
},
))),
checkpoint: None,
pending_cache_key: None,
native_receipt_capture: None,
native_receipt_metadata: None,
input_tokens: 0,
};
assert_eq!(
driver.ask_autopilot(&context).await.unwrap(),
StageDisposition::Skipped(if enabled {
"no canonical autopilot decision"
} else {
"not entitled"
})
);
let Some(PreparedCallResult::Ready(call)) = driver.prepared.as_ref() else {
panic!("prepared call must remain available for dispatch");
};
assert_eq!(
call.decision_context.as_ref().map(|value| &value.task_id),
enabled.then_some(&context.task_id),
"existing completion-accounting handoff must remain intact"
);
if enabled {
assert_eq!(
server.task_envelope.read().await.as_ref().unwrap().task_id,
context.envelope.task_id
);
}
assert!(context.outcome().is_none());
}
}
#[test]
fn replay_admission_overrides_policy_terminal_but_not_response_cache() {
let result = || CallToolResult::success(vec![ContentBlock::text("result")]);
assert!(
PreparedCallResult::Terminal(result(), "policy denied")
.into_replay_override()
.is_some()
);
assert!(
PreparedCallResult::Cached(result())
.into_replay_override()
.is_none()
);
}
#[test]
fn cached_result_preserves_meta() {
let cache = ResponseCache::new(8, Duration::from_mins(1));
let key = response_cache_key("ctx_search", None, "/project").expect("cacheable tool");
let mut result = CallToolResult::success(vec![ContentBlock::text("cached")]);
let mut meta = MetaObject::new();
meta.0
.insert("cache_hint".to_owned(), Value::String("stable".to_owned()));
result.meta = Some(meta);
cache_call_result(&cache, key.clone(), &result);
let cached = cached_call_result(&cache, &key).expect("response should be cached");
assert_eq!(cached.meta, result.meta);
}
#[test]
fn ctx_read_is_not_response_cached() {
assert!(response_cache_key("ctx_read", None, "/project").is_none());
}
#[test]
fn remaining_response_cache_tools_are_cacheable() {
for tool_name in ["ctx_search", "ctx_tree", "ctx_glob"] {
assert!(response_cache_key(tool_name, None, "/project").is_some());
}
}
#[test]
fn active_policy_disables_response_cache_reuse() {
let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(
lean_ctx::core::policy::ResolvedPolicy {
name: "cache-egress-test".into(),
version: "1.0.0".into(),
description: "cache egress guard".into(),
chain: vec![],
default_read_mode: None,
allow_tools: None,
deny_tools: vec![],
max_context_tokens: None,
audit_retention_days: None,
redaction: std::collections::BTreeMap::new(),
filters: lean_ctx::core::policy::FilterRules::default(),
egress: lean_ctx::core::policy::EgressRules::default(),
routing: lean_ctx::core::policy::RoutingPolicyRules::default(),
budgets: lean_ctx::core::policy::BudgetRules::default(),
},
));
assert!(!super::cache_allowed_for_current_egress(
&lean_ctx::core::config::Config::default()
));
}
#[tokio::test(flavor = "multi_thread")]
async fn background_tick_counts_calls_rejected_before_dispatch() {
use std::sync::atomic::Ordering;
let _data_dir = lean_ctx::core::data_dir::isolated_data_dir();
let root = env!("CARGO_MANIFEST_DIR").to_string();
let server = crate::tools::LeanCtxServer::new_with_project_root(Some(&root));
*server.presence_agent_id.write().await = Some("tick-test".to_string());
for expected in 1..=2 {
let rejected = server
.call_tool_guarded(rmcp::model::CallToolRequestParams::new("ctx"))
.await;
assert!(rejected.is_err());
assert_eq!(server.background_tick.load(Ordering::Relaxed), expected);
}
assert_eq!(server.call_count.load(Ordering::Relaxed), 0);
}
#[test]
fn plan_mode_blocks_destructive_tools() {
let plan_tools = lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
for tool in lean_ctx::tool_defs::DESTRUCTIVE_TOOL_NAMES {
assert!(
!plan_tools.contains(tool),
"destructive tool '{tool}' must not be in plan_mode_tools"
);
}
}
#[test]
fn plan_mode_allows_read_only_tools() {
let plan_tools = lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
for tool in ["ctx_read", "ctx_search", "ctx_tree", "ctx_graph"] {
assert!(
plan_tools.contains(&tool),
"read-only tool '{tool}' must be in plan_mode_tools"
);
}
}
#[test]
fn interaction_mode_atomic_swap() {
use crate::tools::InteractionMode;
let mode = std::sync::atomic::AtomicU8::new(InteractionMode::Agent as u8);
assert_eq!(
InteractionMode::from_u8(mode.load(std::sync::atomic::Ordering::Relaxed)),
InteractionMode::Agent,
);
let prev = mode.swap(
InteractionMode::Plan as u8,
std::sync::atomic::Ordering::Relaxed,
);
assert_eq!(prev, InteractionMode::Agent as u8);
assert_eq!(
InteractionMode::from_u8(mode.load(std::sync::atomic::Ordering::Relaxed)),
InteractionMode::Plan,
);
}