use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use oxicode::store::settings::{Settings, SettingsFormat};
use oxicode_sdk::middleware::{HookMiddleware, MiddlewareContext, MiddlewareData, MiddlewarePhase};
use oxicode_sdk::ports::inmem::InMemoryHookRunner;
use oxicode_sdk::ports::{HookEvent, HookOutcome, HookRunner};
#[tokio::test(flavor = "current_thread")]
async fn before_tool_call_runs_hook_and_returns_block() {
let runner = Arc::new(InMemoryHookRunner::new());
runner.on(|_, _| HookOutcome {
block: true,
reason: Some("denied".into()),
..Default::default()
});
let mut pipeline = oxicode_sdk::middleware::MiddlewarePipeline::new();
pipeline = pipeline.add_arc(Arc::new(HookMiddleware::new(
Arc::clone(&runner) as Arc<dyn HookRunner>
)));
let pipeline = Arc::new(pipeline);
let ctx = MiddlewareContext::new(
MiddlewarePhase::BeforeTool,
"agent-1",
MiddlewareData::BeforeTool {
tool_name: "bash".into(),
params: serde_json::json!({"command": "rm -rf /"}),
},
);
let result = pipeline.execute(&ctx).await;
assert!(matches!(
result.action,
oxicode_sdk::middleware::MiddlewareAction::Block
));
assert_eq!(result.reason.as_deref(), Some("denied"));
}
#[tokio::test(flavor = "current_thread")]
async fn after_tool_call_hooks_fire_with_result() {
let seen = Arc::new(AtomicUsize::new(0));
let s = Arc::clone(&seen);
let runner = Arc::new(InMemoryHookRunner::new());
runner.on(move |event, ctx| {
if event == HookEvent::PostToolUse {
s.fetch_add(1, Ordering::SeqCst);
assert_eq!(ctx.tool_name.as_deref(), Some("read"));
assert_eq!(ctx.tool_result.as_deref(), Some("hello"));
}
HookOutcome::default()
});
let mut pipeline = oxicode_sdk::middleware::MiddlewarePipeline::new();
pipeline = pipeline.add_arc(Arc::new(HookMiddleware::new(
Arc::clone(&runner) as Arc<dyn HookRunner>
)));
let pipeline = Arc::new(pipeline);
let ctx = MiddlewareContext::new(
MiddlewarePhase::AfterTool,
"agent-1",
MiddlewareData::AfterTool {
tool_name: "read".into(),
params: serde_json::json!({}),
result: "hello".into(),
},
);
let result = pipeline.execute(&ctx).await;
assert!(matches!(
result.action,
oxicode_sdk::middleware::MiddlewareAction::Continue
));
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"PostToolUse fired exactly once"
);
}
#[tokio::test(flavor = "current_thread")]
async fn subagent_tool_completion_fires_subagent_stop() {
let subagent_count = Arc::new(AtomicUsize::new(0));
let s = Arc::clone(&subagent_count);
let runner = Arc::new(InMemoryHookRunner::new());
runner.on(move |event, _| {
if event == HookEvent::SubagentStop {
s.fetch_add(1, Ordering::SeqCst);
}
HookOutcome::default()
});
let mut pipeline = oxicode_sdk::middleware::MiddlewarePipeline::new();
pipeline = pipeline.add_arc(Arc::new(HookMiddleware::new(
Arc::clone(&runner) as Arc<dyn HookRunner>
)));
let pipeline = Arc::new(pipeline);
let ctx = MiddlewareContext::new(
MiddlewarePhase::AfterTool,
"agent-1",
MiddlewareData::AfterTool {
tool_name: "subagent".into(),
params: serde_json::json!({}),
result: "{}".into(),
},
);
let _ = pipeline.execute(&ctx).await;
assert_eq!(
subagent_count.load(Ordering::SeqCst),
1,
"SubagentStop fired when tool_name == \"subagent\""
);
}
#[test]
fn settings_round_trip_with_hooks() {
let toml = r#"
version = 10
[[hooks]]
event = "SessionStart"
command = "echo started"
"#;
let s = Settings::parse_from_str(toml, SettingsFormat::Toml).unwrap();
assert_eq!(s.hooks.len(), 1);
assert_eq!(s.hooks[0].event, HookEvent::SessionStart);
assert_eq!(s.hooks[0].command, "echo started");
}
#[test]
fn set_hooks_is_called_only_in_agent_builder_build() {
let cli_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut count = 0usize;
for entry in walk_rs_files(&cli_src) {
let text = std::fs::read_to_string(&entry).unwrap();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
if line.contains("\"set_hooks(") {
continue;
}
count += line.match_indices(".set_hooks(").count();
}
}
assert_eq!(
count, 0,
"Expected ZERO .set_hooks( call sites in oxicode-cli/src ({} found). \
Session queues are wired in via AgentBuilder::with_session_hooks \
(sdk); calling set_hooks here would wipe the middleware pipeline.",
count
);
}
fn walk_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(p) = stack.pop() {
if p.is_dir() {
if let Ok(rd) = std::fs::read_dir(&p) {
for e in rd.flatten() {
stack.push(e.path());
}
}
} else if p.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(p);
}
}
out
}