mod session_actor_common;
use session_actor_common::*;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use agent_engine::session::{
AttachMode, ClientKind, ClientMeta, ClientTransport, EndReason, Envelope, LocalTransport,
SessionCommand, SessionConfig, SessionEventWire, SessionHandle, SessionLifecycle,
};
use agent_engine::tools::{SubagentHandle, SubagentState};
use agent_engine::StreamEvent;
use serial_test::serial;
type Registry = Arc<std::sync::Mutex<agent_engine::runtime::subagent::SubagentRegistry>>;
fn register(registry: &Registry, id: &str, agent: &str) -> Arc<RwLock<SubagentState>> {
let state = Arc::new(RwLock::new(SubagentState::new()));
registry.lock().unwrap().register(SubagentHandle::new(
id.into(),
id.trim_start_matches("sa_").parse().unwrap(),
agent.into(),
"work".into(),
MODEL.into(),
String::new(),
300,
Arc::clone(&state),
None,
None,
None,
));
state
}
async fn session(body: &'static str, endless: bool) -> (LocalTransport, SessionHandle, Registry) {
let (url, _) = stub(body, endless).await;
std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
let host = host().await;
let (handle, task) = host.create_session_unspawned(exclusive_cfg()).await.unwrap();
let registry = task.subagent_registry();
tokio::spawn(task.run());
let (t, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
.await
.unwrap();
(t, handle, registry)
}
async fn close(t: &mut LocalTransport, id: u64, arg: &str) {
t.send_from_self(SessionCommand::EngineCommand {
id,
name: "close".into(),
arg: arg.into(),
})
.await
.unwrap();
}
fn reply(seen: &[Envelope], want: u64) -> serde_json::Value {
seen.iter()
.find_map(|e| match &e.event {
SessionEventWire::QueryResult { id, value } if *id == want => Some(value.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("no QueryResult {want}: {seen:#?}"))
}
fn ended(seen: &[Envelope]) -> Option<EndReason> {
seen.iter().find_map(|e| match e.event {
SessionEventWire::Ended { reason } => Some(reason),
_ => None,
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn close_ends_an_idle_session_for_every_client() {
let _h = Home::new();
let (mut t, handle, _) = session(SSE_HI, false).await;
t.send_from_self(submit("hi")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
let (mut other, _) = LocalTransport::attach_with(
handle.clone(),
ClientMeta::new(ClientKind::Attach),
AttachMode::Observe,
)
.await
.unwrap();
close(&mut t, 7, "").await;
let seen = until(&mut t, |e| matches!(e, SessionEventWire::Ended { .. })).await;
let value = reply(&seen, 7);
assert_eq!(value["kind"], "output", "{value}");
let text = value["text"].as_str().unwrap();
assert!(text.starts_with("closing session "), "{text}");
assert!(text.contains("synaps --attach --continue "), "{text}");
assert_eq!(ended(&seen), Some(EndReason::ClientQuit));
let theirs = until(&mut other, |e| matches!(e, SessionEventWire::Ended { .. })).await;
assert_eq!(ended(&theirs), Some(EndReason::ClientQuit));
assert!(tokio::time::timeout(Duration::from_secs(5), t.next_event())
.await
.unwrap()
.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn close_refuses_while_a_worker_runs() {
let _h = Home::new();
let (mut t, _handle, registry) = session(SSE_HI, false).await;
t.send_from_self(submit("start the reviewer"))
.await
.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
let state = register(®istry, "sa_4", "reviewer");
let done = register(®istry, "sa_2", "scout");
done.write().unwrap().status = agent_engine::runtime::subagent::SubagentStatus::Completed;
close(&mut t, 8, "").await;
let seen = until(&mut t, |e| {
matches!(e, SessionEventWire::QueryResult { id: 8, .. })
})
.await;
let value = reply(&seen, 8);
assert_eq!(value["kind"], "error", "{value}");
let text = value["text"].as_str().unwrap();
assert!(
text.contains("1 worker is running (sa_4 reviewer)"),
"{text}"
);
assert!(
!text.contains("sa_2"),
"a finished worker is not a blocker: {text}"
);
assert!(text.contains("/close force"), "{text}");
assert!(
!state.read().unwrap().cancel_requested,
"the worker keeps running"
);
let after = drain_for(&mut t, Duration::from_millis(300)).await;
assert_eq!(ended(&after), None, "the session stays: {after:#?}");
close(&mut t, 9, "now").await;
let seen = until(&mut t, |e| {
matches!(e, SessionEventWire::QueryResult { id: 9, .. })
})
.await;
assert_eq!(reply(&seen, 9)["text"], "usage: /close [force]");
end(&mut t).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn close_force_cancels_the_workers_and_tells_the_conversation() {
let _h = Home::new();
let (mut t, _handle, registry) = session(SSE_HI, false).await;
t.send_from_self(submit("start the reviewer"))
.await
.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
let state = register(®istry, "sa_4", "reviewer");
close(&mut t, 10, "force").await;
let seen = until(&mut t, |e| matches!(e, SessionEventWire::Ended { .. })).await;
assert!(
state.read().unwrap().cancel_requested,
"the worker was cancelled"
);
let note = seen
.iter()
.find_map(|e| match &e.event {
SessionEventWire::External(ev) if ev.content.content_type == "subagent_cancelled" => {
Some(ev.content.text.clone())
}
_ => None,
})
.expect("the conversation is told");
assert!(
note.contains("/close force") && note.contains("'reviewer' (sa_4)"),
"{note}"
);
let conv = last_conversation(&seen);
let last = conv.api_messages.last().unwrap();
assert_eq!(last["role"], "user");
assert!(last["content"].to_string().contains("sa_4"), "{last}");
assert_eq!(reply(&seen, 10)["kind"], "output");
assert_eq!(ended(&seen), Some(EndReason::ClientQuit));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn close_refuses_mid_turn_and_force_ends_it() {
let _h = Home::new();
let (mut t, _handle, _) = session(SSE_PREFIX, true).await;
t.send_from_self(submit("go")).await.unwrap();
until(&mut t, |e| {
matches!(e, SessionEventWire::Stream(StreamEvent::Llm(_)))
})
.await;
close(&mut t, 11, "").await;
let seen = until(&mut t, |e| {
matches!(e, SessionEventWire::QueryResult { id: 11, .. })
})
.await;
let value = reply(&seen, 11);
assert_eq!(value["kind"], "error", "{value}");
assert!(
value["text"]
.as_str()
.unwrap()
.contains("a turn is running"),
"{value}"
);
close(&mut t, 12, "force").await;
let seen = until(&mut t, |e| matches!(e, SessionEventWire::Ended { .. })).await;
assert_eq!(reply(&seen, 12)["kind"], "output");
assert_eq!(ended(&seen), Some(EndReason::ClientQuit));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn only_the_input_owner_can_close() {
let _h = Home::new();
let (mut owner, handle, _) = session(SSE_HI, false).await;
let (mut other, _) = LocalTransport::attach_with(
handle.clone(),
ClientMeta::new(ClientKind::Attach),
AttachMode::Mirror,
)
.await
.unwrap();
assert_eq!(other.input_owner(), Some(owner.client_id()));
close(&mut other, 13, "").await;
let seen = until(&mut other, |e| {
matches!(e, SessionEventWire::Refused { .. })
})
.await;
match &seen.last().unwrap().event {
SessionEventWire::Refused {
client,
command,
reason,
} => {
assert_eq!(*client, other.client_id());
assert_eq!(command, "close");
assert!(reason.contains("input owned by"), "{reason}");
}
_ => unreachable!(),
}
let after = drain_for(&mut owner, Duration::from_millis(300)).await;
assert_eq!(ended(&after), None, "the session stays: {after:#?}");
close(&mut owner, 15, "").await;
let seen = until(&mut owner, |e| matches!(e, SessionEventWire::Ended { .. })).await;
let text = reply(&seen, 15)["text"].as_str().unwrap().to_string();
assert!(text.ends_with("(it never ran a turn, so there is nothing to resume)"), "{text}");
assert!(!text.contains("--continue"), "{text}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn a_parked_session_closes_without_unparking() {
let _h = Home::new();
std::env::set_var("SYNAPS_DAEMON_PARK_GRACE_SECS", "0");
let (url, _) = stub(SSE_HI, false).await;
std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
let host = host().await;
let handle = host
.create_session(SessionConfig {
persist: true,
..cfg()
})
.await
.unwrap();
let (mut t, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
.await
.unwrap();
t.send(submit("hi")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
t.send(SessionCommand::Detach {
client: t.client_id(),
})
.await
.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::ClientLeft { .. })).await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while handle.lifecycle() != SessionLifecycle::Parked && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(25)).await;
}
std::env::remove_var("SYNAPS_DAEMON_PARK_GRACE_SECS");
assert_eq!(handle.lifecycle(), SessionLifecycle::Parked);
let mut rx = handle.subscribe();
handle
.send(SessionCommand::EngineCommand {
id: 14,
name: "close".into(),
arg: String::new(),
})
.await
.unwrap();
let mut lifecycles = Vec::new();
let mut value = None;
let reason = loop {
let env = tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.unwrap()
.unwrap();
match env.event {
SessionEventWire::Lifecycle(l) => lifecycles.push(l),
SessionEventWire::QueryResult { id: 14, value: v } => value = Some(v),
SessionEventWire::Ended { reason } => break reason,
_ => {}
}
};
assert_eq!(value.expect("a reply")["kind"], "output");
assert_eq!(reason, EndReason::ClientQuit);
assert!(
!lifecycles.contains(&SessionLifecycle::Live),
"closing never rebuilt the session: {lifecycles:?}"
);
}