use std::collections::BTreeSet;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use io_harness::observe::{Flow, Observer, RunEvent};
use io_harness::provider::{CompletionRequest, CompletionResponse, ToolCall, Usage};
use io_harness::{
run_with_observed, ApproveAll, Config, Policy, Provider, Store, TaskContract, Verification,
};
use serde_json::json;
static USER: OnceLock<tempfile::TempDir> = OnceLock::new();
fn empty_user_scope() {
let dir = USER.get_or_init(|| tempfile::tempdir().unwrap());
std::env::set_var("IO_CONFIG_HOME", dir.path());
}
struct Mock {
script: Vec<Vec<ToolCall>>,
at: AtomicUsize,
}
impl Provider for Mock {
async fn complete(&self, _req: CompletionRequest) -> io_harness::Result<CompletionResponse> {
let i = self.at.fetch_add(1, Ordering::SeqCst);
Ok(CompletionResponse {
tool_calls: self.script.get(i).cloned().unwrap_or_default(),
usage: Some(Usage {
total_tokens: 7,
..Default::default()
}),
..Default::default()
})
}
fn name(&self) -> &str {
"mock"
}
}
fn mock(script: Vec<Vec<ToolCall>>) -> Mock {
Mock {
script,
at: AtomicUsize::new(0),
}
}
fn call(name: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
name: name.into(),
arguments: args,
}
}
struct Tee<'a>(&'a dyn Observer, &'a dyn Observer);
impl Observer for Tee<'_> {
fn event(&self, event: &RunEvent) -> Flow {
let a = self.0.event(event);
let b = self.1.event(event);
if a.is_cancel() || b.is_cancel() {
Flow::Cancel
} else {
Flow::Continue
}
}
}
#[derive(Default)]
struct Tags(Mutex<Vec<String>>);
impl Observer for Tags {
fn event(&self, event: &RunEvent) -> Flow {
let v = serde_json::to_value(event).unwrap();
self.0
.lock()
.unwrap()
.push(v["event"].as_str().unwrap().to_string());
Flow::Continue
}
}
impl Tags {
fn set(&self) -> BTreeSet<String> {
self.0.lock().unwrap().iter().cloned().collect()
}
}
fn contract(root: &Path) -> TaskContract {
TaskContract::workspace("exercise the hooks", root)
.with_verification(Verification::WorkspaceFileContains {
file: "unreachable.txt".into(),
needle: "never".into(),
})
.with_max_steps(2)
}
fn read_only() -> Policy {
Policy::default()
.layer("test")
.allow_read("*")
.deny_write("*")
}
fn logged(at: &Path) -> Vec<String> {
std::fs::read_to_string(at)
.unwrap()
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["event"]
.as_str()
.unwrap()
.to_string()
})
.collect()
}
#[tokio::test]
async fn f1_a_hook_fires_on_the_events_it_names_and_on_no_others() {
empty_user_scope();
let ws = tempfile::tempdir().unwrap();
std::fs::write(
ws.path().join("io.local.toml"),
r#"
[[hook]]
on = ["refused", "finished"]
append = "named.jsonl"
[[hook]]
append = "everything.jsonl"
[[hook]]
on = ["question_asked"]
append = "never.jsonl"
"#,
)
.unwrap();
let hooks = Config::discover(ws.path()).unwrap().hooks();
let tags = Tags::default();
let store = Store::open(ws.path().join("s.db")).unwrap();
run_with_observed(
&contract(ws.path()),
&mock(vec![vec![call(
"write_file",
json!({"path": "a.txt", "content": "hi"}),
)]]),
&store,
&read_only(),
&ApproveAll,
&Tee(&hooks, &tags),
)
.await
.unwrap();
let named: BTreeSet<String> = logged(&ws.path().join("named.jsonl")).into_iter().collect();
assert_eq!(
named,
BTreeSet::from(["refused".to_string(), "finished".to_string()]),
"a filtered hook received something it did not ask for, or missed something it did"
);
let everything: BTreeSet<String> = logged(&ws.path().join("everything.jsonl"))
.into_iter()
.collect();
assert_eq!(
everything,
tags.set(),
"an unfiltered hook must see exactly the run's own event stream"
);
let never = ws.path().join("never.jsonl");
assert!(never.exists(), "an installed hook creates its log");
assert_eq!(std::fs::read_to_string(&never).unwrap(), "");
}
#[test]
fn f2_an_event_this_crate_does_not_emit_is_refused_naming_it() {
empty_user_scope();
let ws = tempfile::tempdir().unwrap();
std::fs::write(
ws.path().join("io.local.toml"),
"[[hook]]\non = [\"finshed\"]\nappend = \"a.jsonl\"\n",
)
.unwrap();
let err = Config::discover(ws.path()).unwrap_err();
let text = err.to_string();
assert!(text.contains("finshed"), "{text}");
assert!(text.contains("hook[0]"), "{text}");
assert!(text.contains("io.local.toml"), "{text}");
}
#[cfg(unix)]
const CAPTURE: &[&str] = &["sh", "-c", "cat > 'a;b && c.jsonl'"];
#[cfg(windows)]
const CAPTURE: &[&str] = &[
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
"[Console]::In.ReadToEnd() | Set-Content -LiteralPath 'a;b && c.jsonl'",
];
const CAPTURE_FILE: &str = "a;b && c.jsonl";
#[cfg(unix)]
const SLOW: &[&str] = &["sleep", "30"];
#[cfg(windows)]
const SLOW: &[&str] = &["ping", "-n", "31", "127.0.0.1"];
#[cfg(unix)]
const FAST: &[&str] = &["true"];
#[cfg(windows)]
const FAST: &[&str] = &["cmd", "/c", "exit 0"];
#[cfg(unix)]
const FAILS: &[&str] = &["false"];
#[cfg(windows)]
const FAILS: &[&str] = &["cmd", "/c", "exit 1"];
fn argv(parts: &[&str]) -> String {
let items: Vec<String> = parts.iter().map(|p| format!("{p:?}")).collect();
format!("[{}]", items.join(", "))
}
async fn run_under(ws: &Path, hook: &str) -> io_harness::RunOutcome {
std::fs::write(ws.join("io.local.toml"), hook).unwrap();
let hooks = Config::discover(ws).unwrap().hooks();
let store = Store::open(ws.join("s.db")).unwrap();
run_with_observed(
&contract(ws),
&mock(vec![vec![call(
"read_file",
json!({"path": "io.local.toml"}),
)]]),
&store,
&read_only(),
&ApproveAll,
&hooks,
)
.await
.unwrap()
.outcome
}
#[tokio::test]
async fn f4_an_executing_hook_gets_its_argv_whole_and_the_event_on_stdin() {
empty_user_scope();
let ws = tempfile::tempdir().unwrap();
run_under(
ws.path(),
&format!(
"[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 20000\n",
argv(CAPTURE)
),
)
.await;
let at = ws.path().join(CAPTURE_FILE);
assert!(
at.is_file(),
"the argv element did not reach the child intact: {:?}",
std::fs::read_dir(ws.path())
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect::<Vec<_>>()
);
let text = std::fs::read_to_string(&at).unwrap();
let v: serde_json::Value =
serde_json::from_str(text.trim_start_matches('\u{feff}').trim()).unwrap();
assert_eq!(v["event"], "started");
assert_eq!(v["run_id"], 1);
}
#[tokio::test]
async fn f4_a_hook_that_outlives_its_timeout_is_killed_and_reported_as_a_failure() {
empty_user_scope();
let slow = tempfile::tempdir().unwrap();
let outcome = run_under(
slow.path(),
&format!(
"[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 50\non_failure = \"cancel\"\n",
argv(SLOW)
),
)
.await;
assert!(
matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
"a hook past its deadline is a failure: {outcome:?}"
);
let fast = tempfile::tempdir().unwrap();
let outcome = run_under(
fast.path(),
&format!(
"[[hook]]\non = [\"started\"]\nrun = {}\ntimeout_ms = 30000\non_failure = \"cancel\"\n",
argv(FAST)
),
)
.await;
assert!(
!matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
"a hook that succeeded must not stop the run: {outcome:?}"
);
}
#[tokio::test]
async fn f5_a_failing_hook_stops_the_run_only_when_the_operator_asked_it_to() {
empty_user_scope();
let asked = tempfile::tempdir().unwrap();
let outcome = run_under(
asked.path(),
&format!(
"[[hook]]\non = [\"started\"]\nrun = {}\non_failure = \"cancel\"\n",
argv(FAILS)
),
)
.await;
assert!(
matches!(outcome, io_harness::RunOutcome::Cancelled { .. }),
"a local policy check that says no must end the run: {outcome:?}"
);
let unasked = tempfile::tempdir().unwrap();
let outcome = run_under(
unasked.path(),
&format!("[[hook]]\non = [\"started\"]\nrun = {}\n", argv(FAILS)),
)
.await;
assert!(
matches!(outcome, io_harness::RunOutcome::StepCapReached { .. }),
"the default is continue: {outcome:?}"
);
}