use async_trait::async_trait;
use nexara_core::{
ActionClass, AuditRecord, AuditSink, NexaraError, PolicyDecision, ToolCallRequest,
ToolCallResult, ToolDescriptor, TrustProfile, TrustTier,
};
use nexara_runtime::{
HostToolExecutor, NexaraRuntime, NexaraRuntimeConfig, RuntimeLifecycleHooks, StaticToolCatalog,
StaticTrustPolicyResolver,
};
use serde_json::json;
use std::sync::{Arc, Mutex};
use tokio::sync::Notify;
#[derive(Clone)]
struct Ctx;
struct EchoExecutor;
#[async_trait]
impl HostToolExecutor<Ctx> for EchoExecutor {
async fn call_host_tool(
&self,
_tool: &ToolDescriptor,
request: ToolCallRequest,
_context: Ctx,
) -> Result<ToolCallResult, nexara_core::NexaraError> {
Ok(ToolCallResult::new(json!({ "echo": request.params })))
}
}
struct FailingExecutor;
#[async_trait]
impl HostToolExecutor<Ctx> for FailingExecutor {
async fn call_host_tool(
&self,
_tool: &ToolDescriptor,
_request: ToolCallRequest,
_context: Ctx,
) -> Result<ToolCallResult, nexara_core::NexaraError> {
Err(NexaraError::ExecutionFailed("boom".to_string()))
}
}
struct BlockingExecutor {
started: Arc<Notify>,
release: Arc<Notify>,
}
#[async_trait]
impl HostToolExecutor<Ctx> for BlockingExecutor {
async fn call_host_tool(
&self,
_tool: &ToolDescriptor,
_request: ToolCallRequest,
_context: Ctx,
) -> Result<ToolCallResult, nexara_core::NexaraError> {
self.started.notify_one();
self.release.notified().await;
Ok(ToolCallResult::new(json!({ "done": true })))
}
}
#[derive(Default)]
struct RecordingAuditSink {
records: Mutex<Vec<AuditRecord>>,
}
impl AuditSink for RecordingAuditSink {
fn record(&self, record: AuditRecord) -> nexara_core::NexaraResult<()> {
self.records.lock().unwrap().push(record);
Ok(())
}
}
#[derive(Default)]
struct RecordingHooks {
before: Mutex<Vec<String>>,
after: Mutex<Vec<String>>,
}
#[async_trait]
impl RuntimeLifecycleHooks<Ctx> for RecordingHooks {
async fn before_call(&self, tool: &ToolDescriptor, _request: &ToolCallRequest, _context: &Ctx) {
self.before.lock().unwrap().push(tool.name.clone());
}
async fn after_call(
&self,
tool: &ToolDescriptor,
_request: &ToolCallRequest,
outcome: Result<&ToolCallResult, &NexaraError>,
_context: &Ctx,
) {
let suffix = if outcome.is_ok() { "ok" } else { "err" };
self.after
.lock()
.unwrap()
.push(format!("{}:{suffix}", tool.name));
}
}
fn descriptor(action_class: ActionClass) -> ToolDescriptor {
ToolDescriptor {
name: "echo".to_string(),
description: "Echo params".to_string(),
tags: vec!["echo".to_string()],
scopes: vec!["read".to_string()],
trust_tier: TrustTier::Builtin,
action_class,
enabled: true,
input_schema: None,
guidance: None,
}
}
#[tokio::test]
async fn read_call_succeeds() {
let runtime = NexaraRuntime::new(
NexaraRuntimeConfig::default(),
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Read)])),
Arc::new(EchoExecutor),
Arc::new(StaticTrustPolicyResolver::new(TrustProfile::Observe)),
);
let result = runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({ "hello": "world" }),
},
Ctx,
)
.await
.unwrap();
assert_eq!(result.result["echo"]["hello"], "world");
}
#[tokio::test]
async fn write_requires_confirmation() {
let runtime = NexaraRuntime::new(
NexaraRuntimeConfig::default(),
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Write)])),
Arc::new(EchoExecutor),
Arc::new(StaticTrustPolicyResolver::new(
TrustProfile::ActWithConfirmation,
)),
);
let err = runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({}),
},
Ctx,
)
.await
.unwrap_err();
assert!(matches!(
err,
nexara_core::NexaraError::ConfirmationRequired(_)
));
}
#[tokio::test]
async fn confirmed_write_strips_confirmation_marker_before_execution() {
let runtime = NexaraRuntime::new(
NexaraRuntimeConfig::default(),
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Write)])),
Arc::new(EchoExecutor),
Arc::new(StaticTrustPolicyResolver::new(
TrustProfile::ActWithConfirmation,
)),
);
let result = runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({ "_confirmed": true, "payload": "kept" }),
},
Ctx,
)
.await
.unwrap();
assert_eq!(result.result["echo"], json!({ "payload": "kept" }));
}
#[tokio::test]
async fn payload_limit_rejects_oversized_request_before_execution() {
let runtime = NexaraRuntime::new(
NexaraRuntimeConfig {
max_payload_bytes: 4,
..NexaraRuntimeConfig::default()
},
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Read)])),
Arc::new(EchoExecutor),
Arc::new(StaticTrustPolicyResolver::new(TrustProfile::Observe)),
);
let err = runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({ "too": "large" }),
},
Ctx,
)
.await
.unwrap_err();
assert!(matches!(err, NexaraError::PayloadTooLarge(_)));
}
#[tokio::test]
async fn audit_and_lifecycle_hooks_record_successful_and_failed_calls() {
let audit = Arc::new(RecordingAuditSink::default());
let hooks = Arc::new(RecordingHooks::default());
let runtime = NexaraRuntime::new(
NexaraRuntimeConfig::default(),
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Read)])),
Arc::new(EchoExecutor),
Arc::new(StaticTrustPolicyResolver::new(TrustProfile::Observe)),
)
.with_audit_sink(audit.clone())
.with_lifecycle_hooks(hooks.clone());
runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({ "hello": "world" }),
},
Ctx,
)
.await
.unwrap();
{
let records = audit.records.lock().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].tool, "echo");
assert_eq!(records[0].policy_decision, PolicyDecision::Allowed);
assert!(records[0].result_hash.is_some());
}
assert_eq!(hooks.before.lock().unwrap().as_slice(), ["echo"]);
assert_eq!(hooks.after.lock().unwrap().as_slice(), ["echo:ok"]);
let failing_audit = Arc::new(RecordingAuditSink::default());
let failing_hooks = Arc::new(RecordingHooks::default());
let failing_runtime = NexaraRuntime::new(
NexaraRuntimeConfig::default(),
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Read)])),
Arc::new(FailingExecutor),
Arc::new(StaticTrustPolicyResolver::new(TrustProfile::Observe)),
)
.with_audit_sink(failing_audit.clone())
.with_lifecycle_hooks(failing_hooks.clone());
let err = failing_runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({}),
},
Ctx,
)
.await
.unwrap_err();
assert!(matches!(err, NexaraError::ExecutionFailed(_)));
assert_eq!(failing_audit.records.lock().unwrap().len(), 1);
assert_eq!(failing_hooks.after.lock().unwrap().as_slice(), ["echo:err"]);
}
#[tokio::test]
async fn concurrency_limit_rejects_second_call_while_first_is_running() {
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let runtime = Arc::new(NexaraRuntime::new(
NexaraRuntimeConfig {
max_concurrent_calls: 1,
..NexaraRuntimeConfig::default()
},
Arc::new(StaticToolCatalog::new(vec![descriptor(ActionClass::Read)])),
Arc::new(BlockingExecutor {
started: started.clone(),
release: release.clone(),
}),
Arc::new(StaticTrustPolicyResolver::new(TrustProfile::Observe)),
));
let first_runtime = runtime.clone();
let first = tokio::spawn(async move {
first_runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({}),
},
Ctx,
)
.await
});
started.notified().await;
let err = runtime
.call_tool(
ToolCallRequest {
tool: "echo".to_string(),
params: json!({}),
},
Ctx,
)
.await
.unwrap_err();
assert_eq!(err, NexaraError::ConcurrencyLimitExceeded);
release.notify_one();
first.await.unwrap().unwrap();
}