use std::sync::{Arc, Mutex};
use tokio::sync::watch;
use crate::SessionApproval;
use basis::{CancellationToken, PreparedRun, run::TurnOptions};
#[derive(Clone, Debug)]
pub struct Interrupt {
cancelled: watch::Receiver<bool>,
}
impl Interrupt {
pub async fn wait(&mut self) {
let _ = self.cancelled.wait_for(|cancelled| *cancelled).await;
}
}
struct Armed {
token: CancellationToken,
interrupt: watch::Sender<bool>,
}
impl Armed {
fn cancel(&self) {
self.token.cancel();
self.interrupt.send_replace(true);
}
}
#[derive(Clone)]
pub struct HostSession {
run: Arc<tokio::sync::Mutex<PreparedRun>>,
cancel: Arc<Mutex<Option<Armed>>>,
approval: SessionApproval,
agent_id: String,
}
impl HostSession {
pub fn new(run: PreparedRun, approval: SessionApproval) -> Self {
Self {
agent_id: run.agent_id().to_string(),
run: Arc::new(tokio::sync::Mutex::new(run)),
cancel: Arc::new(Mutex::new(None)),
approval,
}
}
pub fn agent_id(&self) -> &str {
&self.agent_id
}
pub fn approval(&self) -> &SessionApproval {
&self.approval
}
pub async fn lock_turn(&self) -> tokio::sync::MutexGuard<'_, PreparedRun> {
self.run.lock().await
}
pub fn begin_turn(&self) -> TurnOptions {
let (options, token) = TurnOptions::cancellable();
let (interrupt, _) = watch::channel(false);
*self.cancel_slot() = Some(Armed { token, interrupt });
options
}
pub fn interrupt(&self) -> Option<Interrupt> {
self.cancel_slot().as_ref().map(|armed| Interrupt {
cancelled: armed.interrupt.subscribe(),
})
}
pub fn end_turn(&self) {
*self.cancel_slot() = None;
}
pub fn cancel(&self) -> bool {
match self.cancel_slot().take() {
Some(armed) => {
armed.cancel();
true
}
None => false,
}
}
fn cancel_slot(&self) -> std::sync::MutexGuard<'_, Option<Armed>> {
self.cancel
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn session() -> HostSession {
let mock = mentra::test::MockRuntime::builder()
.model("mock-model", "openai")
.with_policy(mentra::RuntimePolicy::permissive())
.text("unused")
.build()
.expect("mock runtime builds");
let workspace = tempfile::tempdir().expect("tempdir");
let mentra_session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let run = basis::run::prepare_with_session(
mentra_session,
workspace.path(),
"",
&basis::ContextConfig {
file_name: "AGENTS.md".to_string(),
global_dir: None,
walk_parents: false,
},
"openai",
"mock-model",
)
.expect("prepared");
HostSession::new(run, SessionApproval::new(crate::ApprovalPolicy::Prompt))
}
#[tokio::test]
async fn there_is_nothing_to_interrupt_between_turns() {
let session = session().await;
assert!(session.interrupt().is_none());
assert!(!session.cancel(), "and nothing to cancel");
}
#[tokio::test]
async fn cancelling_wakes_whoever_is_waiting_on_the_turn() {
let session = session().await;
let options = session.begin_turn();
let mut interrupt = session.interrupt().expect("a turn is armed");
assert!(session.cancel());
assert!(options.cancel.expect("armed").is_cancelled());
tokio::time::timeout(std::time::Duration::from_secs(1), interrupt.wait())
.await
.expect("the interrupt must fire");
}
#[tokio::test]
async fn a_turn_that_ended_leaves_no_one_waiting() {
let session = session().await;
let _options = session.begin_turn();
let mut interrupt = session.interrupt().expect("a turn is armed");
session.end_turn();
tokio::time::timeout(std::time::Duration::from_secs(1), interrupt.wait())
.await
.expect("a waiter on a finished turn must not wait forever");
assert!(session.interrupt().is_none());
}
}