use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use localharness::backends::mock::{MockAgentConfig, MockConnection};
use localharness::hooks::{PostTurnHook, PreTurnHook, TurnContext};
use localharness::types::HookResult;
use localharness::{Agent, Content};
struct DenyMarked {
inspected: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl PreTurnHook for DenyMarked {
fn name(&self) -> &str {
"test::deny_marked"
}
async fn run(&self, _ctx: &TurnContext, prompt: &Content) -> localharness::Result<HookResult> {
let text = prompt.as_text().unwrap_or_default();
self.inspected.lock().unwrap().push(text.clone());
if text.contains("BLOCKED") {
Ok(HookResult::deny("the prompt is on the blocklist"))
} else {
Ok(HookResult::allow())
}
}
}
struct RecordingPostTurn {
seen: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl PostTurnHook for RecordingPostTurn {
fn name(&self) -> &str {
"test::recording_post_turn"
}
async fn run(&self, _ctx: &TurnContext, response: &str) -> localharness::Result<()> {
self.seen.lock().unwrap().push(response.to_string());
Ok(())
}
}
#[tokio::test]
async fn pre_turn_deny_blocks_the_turn_and_leaves_history_clean() {
let backend = MockConnection::builder()
.turn(|t| t.text("the only scripted answer"))
.build();
let agent = Agent::start_mock(MockAgentConfig::new(backend))
.await
.expect("mock agent starts");
let inspected = Arc::new(Mutex::new(Vec::new()));
let post_seen = Arc::new(Mutex::new(Vec::new()));
agent.hooks().register_pre_turn(Arc::new(DenyMarked {
inspected: inspected.clone(),
}));
agent.hooks().register_post_turn(Arc::new(RecordingPostTurn {
seen: post_seen.clone(),
}));
let denied = agent
.chat("this prompt is BLOCKED")
.await
.expect("send dispatches; the deny surfaces on the stream");
let err = denied
.text()
.await
.expect_err("a denied turn must surface an Err, not an empty success");
let msg = err.to_string();
assert!(
msg.contains("turn denied by hook: the prompt is on the blocklist"),
"the Err must carry the deny reason, got: {msg}"
);
assert_eq!(
inspected.lock().unwrap().as_slice(),
["this prompt is BLOCKED"],
"the pre-turn hook saw the prompt"
);
assert!(
post_seen.lock().unwrap().is_empty(),
"post-turn hooks must NOT fire for a denied turn"
);
let reply = agent
.chat("a clean prompt")
.await
.expect("chat starts")
.text()
.await
.expect("the allowed turn completes");
assert_eq!(
reply, "the only scripted answer",
"the denied turn must not have consumed the first scripted model turn"
);
agent.shutdown().await.expect("clean shutdown");
}
#[tokio::test]
async fn post_turn_hook_fires_after_each_successful_turn() {
let backend = MockConnection::builder()
.turn(|t| t.text("first answer"))
.turn(|t| t.text("second answer"))
.build();
let agent = Agent::start_mock(MockAgentConfig::new(backend))
.await
.expect("mock agent starts");
let seen = Arc::new(Mutex::new(Vec::new()));
agent.hooks().register_post_turn(Arc::new(RecordingPostTurn {
seen: seen.clone(),
}));
let r1 = agent.chat("one").await.unwrap().text().await.unwrap();
assert_eq!(r1, "first answer");
let r2 = agent.chat("two").await.unwrap().text().await.unwrap();
assert_eq!(r2, "second answer");
agent
.conversation()
.connection()
.wait_for_idle()
.await
.expect("turn settles");
assert_eq!(
seen.lock().unwrap().as_slice(),
["first answer", "second answer"],
"the post-turn hook observes each completed turn's final text, in order"
);
agent.shutdown().await.expect("clean shutdown");
}