mod session_actor_common;
use session_actor_common::*;
use std::time::Duration;
use agent_engine::session::wire::CHECKPOINT_QUERY_ID;
use agent_engine::session::{
CheckpointReason, ClientKind, ClientMeta, ClientTransport, EndReason, LocalTransport,
SessionCommand, SessionConfig, SessionEventWire, SessionHandle, SessionLifecycle,
SessionReloadRecord,
};
use serial_test::serial;
struct Grace;
impl Grace {
fn zero() -> Self {
std::env::set_var("SYNAPS_DAEMON_PARK_GRACE_SECS", "0");
Self
}
}
impl Drop for Grace {
fn drop(&mut self) {
std::env::remove_var("SYNAPS_DAEMON_PARK_GRACE_SECS");
}
}
fn home_with_memory_default() -> Home {
let h = Home::new();
std::fs::write(
h.dir.path().join(".synaps-cli").join("config"),
"memory.default_mode = capture_and_recall\nmemory.default_mode_confirmed = true\n",
)
.unwrap();
h
}
fn pcfg() -> SessionConfig {
SessionConfig {
persist: true,
..cfg()
}
}
async fn attach(handle: &SessionHandle) -> LocalTransport {
LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
.await
.unwrap()
.0
}
async fn memory(t: &mut LocalTransport, id: u64, arg: &str) -> String {
t.send(SessionCommand::EngineCommand {
id,
name: "memory".into(),
arg: arg.into(),
})
.await
.unwrap();
let seen = until(t, |e| matches!(e, SessionEventWire::QueryResult { id: i, .. } if *i == id)).await;
match &seen.last().unwrap().event {
SessionEventWire::QueryResult { value, .. } => value["text"].as_str().unwrap_or("").to_string(),
_ => unreachable!(),
}
}
async fn parked(handle: &SessionHandle) -> bool {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while tokio::time::Instant::now() < deadline {
if handle.lifecycle() == SessionLifecycle::Parked {
return true;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
false
}
async fn park_and_unpark(handle: &SessionHandle, t: LocalTransport) -> LocalTransport {
t.send(SessionCommand::Detach { client: t.client_id() }).await.unwrap();
drop(t);
assert!(parked(handle).await, "grace 0 → parked");
attach(handle).await
}
async fn session() -> (SessionHandle, LocalTransport) {
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(pcfg()).await.unwrap();
let mut t = attach(&handle).await;
assert!(
memory(&mut t, 1, "status").await.contains("mode capture_and_recall"),
"the confirmed default applies to a new session"
);
t.send(submit("hello")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
(handle, t)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn memory_off_survives_park_and_unpark() {
let _h = home_with_memory_default();
let _g = Grace::zero();
let (handle, mut t) = session().await;
t = park_and_unpark(&handle, t).await;
assert!(memory(&mut t, 2, "status").await.contains("mode capture_and_recall"));
assert!(memory(&mut t, 3, "off").await.contains("mode off"));
t = park_and_unpark(&handle, t).await;
let status = memory(&mut t, 4, "status").await;
assert!(status.contains("mode off"), "/memory off was undone by the unpark: {status}");
assert!(memory(&mut t, 5, "recall").await.contains("mode recall_each_prompt"));
t = park_and_unpark(&handle, t).await;
let status = memory(&mut t, 6, "status").await;
assert!(status.contains("mode recall_each_prompt"), "{status}");
end(&mut t).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn memory_off_survives_a_daemon_reload() {
let _h = home_with_memory_default();
let (handle, mut t) = session().await;
assert!(memory(&mut t, 2, "off").await.contains("mode off"));
handle
.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload })
.await
.unwrap();
let seen = until(&mut t, |e| {
matches!(e, SessionEventWire::QueryResult { id, .. } if *id == CHECKPOINT_QUERY_ID)
})
.await;
let SessionEventWire::QueryResult { value, .. } = &seen.last().unwrap().event else {
unreachable!()
};
let record: SessionReloadRecord = serde_json::from_value(value["record"].clone()).unwrap();
let journal = handle.journal_id();
t.send(SessionCommand::End { reason: EndReason::ClientQuit }).await.unwrap();
while handle.is_alive() {
tokio::time::sleep(Duration::from_millis(25)).await;
}
let host = host().await;
let reborn = host
.create_session(SessionConfig {
continue_session: Some(Some(journal)),
..record.config
})
.await
.unwrap();
let mut t = attach(&reborn).await;
let status = memory(&mut t, 3, "status").await;
assert!(status.contains("mode off"), "/memory off was undone by the reload: {status}");
end(&mut t).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn consent_revoked_outside_slash_memory_survives_park_and_reload() {
let _h = home_with_memory_default();
let _g = Grace::zero();
let (url, _) = stub(SSE_HI, false).await;
std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
let host = host().await;
let (handle, task) = host.create_session_unspawned(pcfg()).await.unwrap();
let runtime = task.runtime_for_harness();
tokio::spawn(task.run());
let mut t = attach(&handle).await;
assert!(memory(&mut t, 1, "status").await.contains("mode capture_and_recall"));
t.send(submit("hello")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
runtime.memory_context_disable();
assert!(memory(&mut t, 2, "status").await.contains("mode off"));
t.send(submit("carry on")).await.unwrap();
let seen = until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
assert!(
seen.iter().any(|e| matches!(
&e.event,
SessionEventWire::SystemNotice(n) if n.starts_with("memory stays off for this session")
)),
"the user is told the revocation is kept"
);
t = park_and_unpark(&handle, t).await;
let status = memory(&mut t, 3, "status").await;
assert!(status.contains("mode off"), "the revocation was undone by the unpark: {status}");
drop(runtime);
handle
.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload })
.await
.unwrap();
let seen = until(&mut t, |e| {
matches!(e, SessionEventWire::QueryResult { id, .. } if *id == CHECKPOINT_QUERY_ID)
})
.await;
let SessionEventWire::QueryResult { value, .. } = &seen.last().unwrap().event else {
unreachable!()
};
let record: SessionReloadRecord = serde_json::from_value(value["record"].clone()).unwrap();
assert_eq!(
record.config.memory_mode,
Some(agent_engine::session::SessionMemoryMode::Off),
"the reload record carries the revocation"
);
end(&mut t).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn a_revocation_after_slash_memory_on_is_not_re_granted() {
let _h = home_with_memory_default();
let _g = Grace::zero();
let (url, _) = stub(SSE_HI, false).await;
std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
let host = host().await;
let (handle, task) = host.create_session_unspawned(pcfg()).await.unwrap();
let runtime = task.runtime_for_harness();
tokio::spawn(task.run());
let mut t = attach(&handle).await;
assert!(memory(&mut t, 1, "recall").await.contains("mode recall_each_prompt"));
t.send(submit("hello")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
runtime.memory_context_disable();
drop(runtime);
t = park_and_unpark(&handle, t).await;
let status = memory(&mut t, 2, "status").await;
assert!(status.contains("mode off"), "{status}");
end(&mut t).await;
}
async fn recorded_memory_mode(
handle: &SessionHandle,
t: &mut LocalTransport,
) -> Option<agent_engine::session::SessionMemoryMode> {
handle
.send(SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest })
.await
.unwrap();
let seen = until(t, |e| {
matches!(e, SessionEventWire::QueryResult { id, .. } if *id == CHECKPOINT_QUERY_ID)
})
.await;
let SessionEventWire::QueryResult { value, .. } = &seen.last().unwrap().event else {
unreachable!()
};
serde_json::from_value::<SessionReloadRecord>(value["record"].clone())
.unwrap()
.config
.memory_mode
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn a_grant_that_failed_on_rebuild_is_retried_not_recorded_off() {
use agent_engine::runtime::memory_context::testing::ProviderUnavailable;
use agent_engine::session::SessionMemoryMode;
let _h = home_with_memory_default();
let _g = Grace::zero();
let (handle, mut t) = session().await;
assert!(memory(&mut t, 2, "recall").await.contains("mode recall_each_prompt"));
let unavailable = ProviderUnavailable::set();
t = park_and_unpark(&handle, t).await;
assert!(memory(&mut t, 3, "status").await.contains("mode off"), "the restore failed");
t.send(submit("meanwhile")).await.unwrap();
let seen = until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
assert!(
!seen.iter().any(|e| matches!(
&e.event,
SessionEventWire::SystemNotice(n) if n.starts_with("memory stays off")
)),
"nobody revoked anything"
);
assert_eq!(
recorded_memory_mode(&handle, &mut t).await,
Some(SessionMemoryMode::RecallEachPrompt),
"the user's choice stands"
);
drop(unavailable);
t = park_and_unpark(&handle, t).await;
let status = memory(&mut t, 4, "status").await;
assert!(status.contains("mode recall_each_prompt"), "the grant is retried: {status}");
end(&mut t).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn a_default_grant_that_failed_on_rebuild_is_retried() {
use agent_engine::runtime::memory_context::testing::ProviderUnavailable;
let _h = home_with_memory_default();
let _g = Grace::zero();
let (handle, mut t) = session().await;
let unavailable = ProviderUnavailable::set();
t = park_and_unpark(&handle, t).await;
assert!(memory(&mut t, 2, "status").await.contains("mode off"));
t.send(submit("meanwhile")).await.unwrap();
until(&mut t, |e| matches!(e, SessionEventWire::Idle)).await;
assert_eq!(recorded_memory_mode(&handle, &mut t).await, None, "still no choice");
drop(unavailable);
t = park_and_unpark(&handle, t).await;
assert!(memory(&mut t, 3, "status").await.contains("mode capture_and_recall"));
end(&mut t).await;
}