use std::sync::Arc;
use agent_engine::tools::activation::{
DropReason, ExecutionGate, ResolvedToolCall, SessionId, SessionToolSet,
ToolAuthorizationError,
};
use agent_engine::tools::catalog::{
CapabilityRecord, CapabilitySource, SchemaLocator, SessionActivationGrant, ToolCatalog, ToolId,
TrustProvenance,
};
use agent_engine::tools::{Tool, ToolContext, ToolOrigin};
use agent_engine::{Result, Value};
use async_trait::async_trait;
struct FixtureTool;
#[async_trait]
impl Tool for FixtureTool {
fn name(&self) -> &str {
"fixture"
}
fn description(&self) -> &str {
"carry-forward fixture"
}
fn parameters(&self) -> Value {
serde_json::json!({"type": "object"})
}
fn origin(&self) -> ToolOrigin {
ToolOrigin::Unknown
}
async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
panic!("fixture must never execute");
}
}
fn record(id: &str, schema: Value, server: &str) -> CapabilityRecord {
CapabilityRecord::new(
ToolId::parse(id).expect("canonical id"),
CapabilitySource::Mcp {
server_id: server.to_string(),
server_tool_name: id.rsplit(':').next().unwrap_or(id).to_string(),
},
"carry-forward fixture capability",
Vec::new(),
SchemaLocator::Inline(schema),
Arc::new(|| -> Arc<dyn Tool> { Arc::new(FixtureTool) }),
TrustProvenance::McpConfig {
server_id: server.to_string(),
},
)
}
fn mcp(id: &str) -> CapabilityRecord {
record(id, serde_json::json!({"type": "object"}), "server-1")
}
fn session(raw: &str) -> SessionId {
SessionId::parse(raw).expect("valid session id")
}
fn grant_for(session: &SessionId, catalog: &ToolCatalog, id: &ToolId) -> SessionActivationGrant {
let rec = catalog.get(id).expect("present");
SessionActivationGrant::new(
session.as_str(),
id.clone(),
catalog.generation(),
rec.schema_digest().clone(),
)
.expect("valid grant")
}
fn resolved(id: &ToolId) -> ResolvedToolCall {
ResolvedToolCall::new("wire", "runtime", id.clone())
}
fn fixture() -> (ToolCatalog, SessionToolSet, ToolId, ToolId, ToolId) {
let mut catalog = ToolCatalog::empty();
let core = mcp("mcp.server-1:core");
let a = mcp("mcp.server-1:alpha");
let b = mcp("mcp.server-1:beta");
let (core_id, a_id, b_id) = (core.id().clone(), a.id().clone(), b.id().clone());
catalog.insert(core).unwrap();
catalog.insert(a).unwrap();
catalog.insert(b).unwrap();
let sid = session("s-carry");
let mut set = SessionToolSet::new(sid.clone(), vec![core_id.clone()], &catalog).unwrap();
set.activate(grant_for(&sid, &catalog, &a_id), &catalog).unwrap();
set.activate(grant_for(&sid, &catalog, &b_id), &catalog).unwrap();
assert_eq!(set.schema_generation(), 2);
(catalog, set, core_id, a_id, b_id)
}
#[test]
fn unrelated_generation_bump_carries_every_activation() {
let (mut catalog, set, _core, a, b) = fixture();
catalog.insert(mcp("mcp.server-1:unrelated")).unwrap();
assert!(set.is_stale(&catalog));
let (next, dropped) = set.rebuilt_for_catalog(&catalog, true);
assert!(dropped.is_empty());
assert!(!next.is_stale(&catalog));
assert!(next.activation(&a).is_some());
assert!(next.activation(&b).is_some());
assert_eq!(
next.activation(&a).unwrap().catalog_generation(),
catalog.generation()
);
assert_eq!(
next.activation(&a).unwrap().schema_digest(),
catalog.get(&a).unwrap().schema_digest()
);
assert_eq!(next.schema_generation(), set.schema_generation());
ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect("carried activation authorizes");
}
#[test]
fn digest_drift_drops_only_that_activation() {
let (mut catalog, set, _core, a, b) = fixture();
let changed = record(
"mcp.server-1:alpha",
serde_json::json!({"type": "object", "properties": {"x": {"type": "string"}}}),
"server-1",
);
catalog.upsert(Some(&a), changed).unwrap();
let (next, dropped) = set.rebuilt_for_catalog(&catalog, true);
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].id, a);
assert_eq!(dropped[0].reason, DropReason::Drifted);
assert!(next.activation(&a).is_none());
assert!(next.activation(&b).is_some());
assert_eq!(next.schema_generation(), set.schema_generation() + 1);
let err = ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect_err("drifted denied");
assert_eq!(err, ToolAuthorizationError::NotActivated(a));
}
#[test]
fn provenance_drift_drops_the_activation() {
let (mut catalog, set, _core, a, b) = fixture();
let imposter = record("mcp.server-1:alpha", serde_json::json!({"type": "object"}), "server-2");
catalog.upsert(Some(&a), imposter).unwrap();
let (next, dropped) = set.rebuilt_for_catalog(&catalog, true);
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].reason, DropReason::Drifted);
assert!(next.activation(&a).is_none());
assert!(next.activation(&b).is_some());
}
#[test]
fn source_provenance_mismatch_drops_the_activation() {
let (mut catalog, set, _core, a, b) = fixture();
let mismatched = CapabilityRecord::new(
a.clone(),
CapabilitySource::Mcp {
server_id: "server-2".to_string(),
server_tool_name: "alpha".to_string(),
},
"carry-forward fixture capability",
Vec::new(),
SchemaLocator::Inline(serde_json::json!({"type": "object"})),
Arc::new(|| -> Arc<dyn Tool> { Arc::new(FixtureTool) }),
TrustProvenance::McpConfig {
server_id: "server-1".to_string(),
},
);
catalog.upsert(Some(&a), mismatched).unwrap();
let (next, dropped) = set.rebuilt_for_catalog(&catalog, true);
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].id, a);
assert_eq!(dropped[0].reason, DropReason::Drifted);
assert!(next.activation(&a).is_none(), "not exposed in the schema");
assert!(next.activation(&b).is_some());
}
#[test]
fn removed_tool_is_reported_removed() {
let (catalog, set, _core, a, b) = fixture();
let mut without = ToolCatalog::empty();
without.insert(mcp("mcp.server-1:core")).unwrap();
without.insert(mcp("mcp.server-1:beta")).unwrap();
without.rebase_past(catalog.generation()).unwrap();
assert!(set.is_stale(&without));
let (next, dropped) = set.rebuilt_for_catalog(&without, true);
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].id, a);
assert_eq!(dropped[0].reason, DropReason::Removed);
assert!(next.activation(&b).is_some());
assert_eq!(next.schema_generation(), set.schema_generation() + 1);
}
#[test]
fn activation_promoted_to_core_is_not_duplicated() {
let (mut catalog, set, _core, a, b) = fixture();
catalog.insert(mcp("mcp.server-1:unrelated")).unwrap();
let (next, dropped) = set.rebuilt_for_catalog(&catalog, false);
assert!(dropped.is_empty());
assert!(next.is_core(&a));
assert!(next.is_core(&b));
assert_eq!(next.activated().count(), 0);
assert_eq!(next.schema_generation(), set.schema_generation());
ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect("core authorizes");
}
#[test]
fn carried_activation_still_denied_when_record_drifts_after_rebuild() {
let (mut catalog, set, _core, a, _b) = fixture();
catalog.insert(mcp("mcp.server-1:unrelated")).unwrap();
let (next, _) = set.rebuilt_for_catalog(&catalog, true);
ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect("carried authorizes");
let changed = record(
"mcp.server-1:alpha",
serde_json::json!({"type": "object", "properties": {"y": {"type": "number"}}}),
"server-1",
);
catalog.upsert(Some(&a), changed).unwrap();
let err = ExecutionGate::authorize(&catalog, &next, resolved(&a))
.expect_err("digest drift after rebuild must still deny");
assert!(
!matches!(err, ToolAuthorizationError::NotActivated(_)),
"denied for drift, not for missing activation: {err:?}"
);
}
#[test]
fn kill_switch_restores_zero_inherit_rebuild() {
std::env::set_var("SYNAPS_TOOLSET_CARRY_FORWARD", "0");
assert!(!agent_engine::tools::activation::carry_forward_enabled());
std::env::remove_var("SYNAPS_TOOLSET_CARRY_FORWARD");
assert!(agent_engine::tools::activation::carry_forward_enabled());
let (mut catalog, set, _core, a, _b) = fixture();
catalog.insert(mcp("mcp.server-1:unrelated")).unwrap();
let old = SessionToolSet::new(set.session().clone(), Vec::new(), &catalog).unwrap();
assert_eq!(old.activated().count(), 0);
let err = ExecutionGate::authorize(&catalog, &old, resolved(&a)).expect_err("zero-inherit");
assert_eq!(err, ToolAuthorizationError::NotActivated(a));
}
#[test]
fn turn_start_carry_keeps_activations_and_the_batch_counter() {
let (catalog, set, core, a, b) = fixture();
let fresh = SessionToolSet::new(set.session().clone(), vec![core], &catalog).unwrap();
assert_eq!(fresh.activated().count(), 0);
let (next, dropped) = set.carry_activations_into(fresh, &catalog);
assert!(dropped.is_empty());
assert!(next.activation(&a).is_some());
assert!(next.activation(&b).is_some());
assert_eq!(
next.schema_generation(),
set.schema_generation(),
"carrying is not an activation batch — the counter continues"
);
ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect("carried grant authorizes");
}
#[test]
fn turn_start_carry_re_derives_the_core_from_the_new_surface() {
let (catalog, set, _core, a, _b) = fixture();
let fresh = SessionToolSet::new(set.session().clone(), vec![a.clone()], &catalog).unwrap();
let (next, dropped) = set.carry_activations_into(fresh, &catalog);
assert!(dropped.is_empty());
assert!(next.is_core(&a));
assert!(next.activation(&a).is_none());
assert_eq!(next.activated().count(), 1);
}
#[test]
fn turn_start_carry_never_crosses_sessions() {
let (catalog, set, core, a, _b) = fixture();
let other = SessionToolSet::new(session("s-other"), vec![core], &catalog).unwrap();
let (next, dropped) = set.carry_activations_into(other, &catalog);
assert!(dropped.is_empty());
assert_eq!(next.session(), &session("s-other"));
assert_eq!(next.activated().count(), 0, "grants never cross sessions");
let err = ExecutionGate::authorize(&catalog, &next, resolved(&a)).expect_err("other session");
assert_eq!(err, ToolAuthorizationError::NotActivated(a));
}