use super::{SessionDefaults, load_session};
use crate::{Cli, GlobalOptions};
use saya_store::{FsSessionStore, RedactedMessage, RedactedSession, SessionStore};
fn cli() -> Cli {
Cli {
options: GlobalOptions {
continue_session: true,
..Default::default()
},
command: None,
}
}
#[test]
fn v1_session_uses_current_runtime_defaults() {
let root = std::env::temp_dir().join(format!("saya-v1-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: 1,
id: "legacy".into(),
profile_names: vec!["analytics".into()],
messages: vec![],
..Default::default()
}))
.unwrap();
let state = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "openai_compatible".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "read-only".into(),
},
)
.unwrap();
assert_eq!(state.provider, "openai_compatible");
assert_eq!(state.model, "current-model");
assert!(state.allow_data_sharing);
assert_eq!(state.approval_mode, "read-only");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn raw_legacy_json_without_version_uses_current_privacy_defaults() {
let root = std::env::temp_dir().join(format!("saya-raw-v1-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("raw.json"),
r#"{"id":"raw","profile_names":[],"messages":[]}"#,
)
.unwrap();
let state = load_session(
&FsSessionStore::new(&root),
&cli(),
&SessionDefaults {
provider: "openai".into(),
model: "runtime-model".into(),
allow_data_sharing: true,
approval_mode: "read-only".into(),
},
)
.unwrap();
assert_eq!(state.provider, "openai");
assert_eq!(state.model, "runtime-model");
assert!(state.allow_data_sharing);
assert_eq!(state.approval_mode, "read-only");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn v2_session_restores_persisted_settings() {
let root = std::env::temp_dir().join(format!("saya-v2-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "saved".into(),
provider: "ollama".into(),
model: "saved-model".into(),
allow_data_sharing: false,
approval_mode: "never".into(),
..Default::default()
}))
.unwrap();
let state = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "openai".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(state.provider, "ollama");
assert_eq!(state.model, "saved-model");
assert!(!state.allow_data_sharing);
assert_eq!(state.approval_mode, "never");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_messages_migrate_to_one_safe_turn() {
let root = std::env::temp_dir().join(format!("saya-migrate-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: 1,
id: "legacy-messages".into(),
messages: vec![
RedactedMessage {
role: "system".into(),
content: "old command".into(),
},
RedactedMessage {
role: "user".into(),
content: "question".into(),
},
RedactedMessage {
role: "assistant".into(),
content: "answer".into(),
},
],
..Default::default()
}))
.unwrap();
let state = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "model".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(state.turns.len(), 1);
assert_eq!(state.provider_history().len(), 2);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn resume_honors_an_explicit_approval_mode_override() {
let root = std::env::temp_dir().join(format!("saya-resume-override-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "override".into(),
approval_mode: "read-only".into(),
..Default::default()
}))
.unwrap();
let cli = Cli {
options: GlobalOptions {
continue_session: true,
approval_mode: Some("never".into()),
..Default::default()
},
command: None,
};
let mut state = load_session(
&store,
&cli,
&SessionDefaults {
provider: "openai".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "never".into(),
},
)
.unwrap();
assert_eq!(state.approval_mode, "read-only");
state.approval_mode =
super::super::session_loop::resume_approval_mode(&cli.options, &state.approval_mode)
.unwrap();
assert_eq!(state.approval_mode, "never");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn resume_without_the_flag_keeps_the_persisted_mode() {
let root = std::env::temp_dir().join(format!("saya-resume-keep-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "keep".into(),
approval_mode: "read-only".into(),
..Default::default()
}))
.unwrap();
let cli = cli();
let mut state = load_session(
&store,
&cli,
&SessionDefaults {
provider: "openai".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(state.approval_mode, "read-only");
state.approval_mode =
super::super::session_loop::resume_approval_mode(&cli.options, &state.approval_mode)
.unwrap();
assert_eq!(state.approval_mode, "read-only");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_plan_session_resumes_as_plan_with_its_status_word() {
use saya_agent::AgentMode;
let root = std::env::temp_dir().join(format!("saya-mode-resume-{}", std::process::id()));
let store = FsSessionStore::new(&root);
let mut state = crate::SessionState::new("planned", None, "m");
state.agent_mode = AgentMode::Plan.as_str().into();
super::block_on(store.save(state.redacted())).unwrap();
let resumed = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(resumed.agent_mode, "plan");
assert_eq!(resumed.agent_mode_parsed(), AgentMode::Plan);
assert!(
super::super::session_prompt::status_line(&resumed).contains("mode:plan"),
"the status line names the resumed posture"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_session_file_without_the_mode_field_resumes_as_build() {
use saya_agent::AgentMode;
let root = std::env::temp_dir().join(format!("saya-mode-legacy-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("old.json"),
r#"{"version":2,"id":"old","profile_names":[],"turns":[],"messages":[]}"#,
)
.unwrap();
let state = load_session(
&FsSessionStore::new(&root),
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(state.agent_mode, "build");
assert_eq!(state.agent_mode_parsed(), AgentMode::Build);
assert!(
super::super::session_prompt::status_line(&state).contains("mode:build"),
"the old session reads exactly as before"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_resumed_plan_session_starts_with_an_empty_grant_store() {
let state = super::state_from_redacted(
RedactedSession {
version: saya_store::SESSION_VERSION,
id: "planned-grants".into(),
agent_mode: "plan".into(),
..Default::default()
},
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
);
assert_eq!(state.agent_mode, "plan");
let policy = saya_agent::SessionPolicy::new(saya_agent::ApprovalPolicy::Ask);
assert!(
policy.grants().is_empty(),
"a resumed session builds its policy empty, from the mode alone"
);
}
#[test]
fn the_mode_is_an_additive_field_at_the_current_session_version() {
assert_eq!(saya_store::SESSION_VERSION, 2);
let record: RedactedSession = serde_json::from_str(
r#"{"version":2,"id":"old","profile_names":[],"turns":[],"messages":[]}"#,
)
.expect("a record without the mode field deserializes");
assert!(
record.agent_mode.is_empty(),
"a pre-slice record carries no mode"
);
}
#[test]
fn a_task_list_round_trips_through_persist_and_resume() {
use saya_types::{SessionTask, SessionTaskList, TaskStatus};
let root = std::env::temp_dir().join(format!("saya-tasks-resume-{}", std::process::id()));
let store = FsSessionStore::new(&root);
let mut state = crate::SessionState::new("tasked", None, "m");
state.task_list = SessionTaskList::new(vec![
SessionTask::with_note(
"profile the tables",
TaskStatus::InProgress,
Some("halfway"),
)
.unwrap(),
SessionTask::new("write the report", TaskStatus::Pending).unwrap(),
])
.unwrap();
super::block_on(store.save(state.redacted())).unwrap();
let resumed = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(resumed.task_list, state.task_list);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_session_file_without_the_task_list_field_resumes_empty() {
let root = std::env::temp_dir().join(format!("saya-tasks-legacy-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("old.json"),
r#"{"version":2,"id":"old","profile_names":[],"turns":[],"messages":[]}"#,
)
.unwrap();
let state = load_session(
&FsSessionStore::new(&root),
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert!(
state.task_list.is_empty(),
"a pre-slice record resumes with an empty list"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_session_file_with_an_invalid_task_list_resumes_empty_without_error() {
let root = std::env::temp_dir().join(format!("saya-tasks-invalid-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("bad.json"),
r#"{"version":2,"id":"bad","profile_names":[],"turns":[],"messages":[],"task_list":{"tasks":[{"title":"one","status":"in_progress"},{"title":"two","status":"in_progress"}]}}"#,
)
.unwrap();
let state = load_session(
&FsSessionStore::new(&root),
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.expect("an invalid stored list must not fail the resume");
assert!(
state.task_list.is_empty(),
"an invalid stored list resumes empty"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn the_task_list_is_an_additive_field_at_the_current_session_version() {
assert_eq!(saya_store::SESSION_VERSION, 2);
let record: RedactedSession = serde_json::from_str(
r#"{"version":2,"id":"old","profile_names":[],"turns":[],"messages":[]}"#,
)
.expect("a record without the task-list field deserializes");
assert!(
record.task_list.is_empty(),
"a pre-slice record carries no tasks"
);
}
#[test]
fn an_old_session_file_without_the_new_tool_fields_still_loads() {
let root = std::env::temp_dir().join(format!("saya-old-tool-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("old.json"),
r#"{"version":2,"id":"old","profile_names":["analytics"],"turns":[{"user":"q","assistant":"a","database_derived":true,"tools":[{"name":"bounded_sql_query","status":"completed"}]}],"messages":[]}"#,
)
.unwrap();
let state = load_session(
&FsSessionStore::new(&root),
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
let tool = &state.turns[0].tools[0];
assert_eq!(tool.name, "bounded_sql_query");
assert_eq!(tool.status, "completed");
assert_eq!(tool.arguments, "", "missing arguments default to empty");
assert!(
tool.result_shape.is_none(),
"missing result_shape defaults to None"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn the_recorded_workspace_root_rides_the_resume() {
let root = std::env::temp_dir().join(format!("saya-ws-pin-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "pinned".into(),
workspace_root: Some("/projects/saya".into()),
..Default::default()
}))
.unwrap();
let state = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "openai".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(
state.workspace_root.as_deref(),
Some("/projects/saya"),
"the pin follows the record, not the resume cwd"
);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "legacy".into(),
..Default::default()
}))
.unwrap();
let legacy = Cli {
options: GlobalOptions {
resume: Some("legacy".into()),
..Default::default()
},
command: None,
};
let old = load_session(
&store,
&legacy,
&SessionDefaults {
provider: "openai".into(),
model: "current-model".into(),
allow_data_sharing: true,
approval_mode: "ask".into(),
},
)
.unwrap();
assert!(
old.workspace_root.is_none(),
"no record, no pin: the old session resumes unbound"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_resumed_session_carries_bypass_and_reprints_the_line() {
let root = std::env::temp_dir().join(format!("saya-bypass-resume-{}", std::process::id()));
let store = FsSessionStore::new(&root);
super::block_on(store.save(RedactedSession {
version: saya_store::SESSION_VERSION,
id: "bypassed".into(),
approval_mode: "bypass".into(),
..Default::default()
}))
.unwrap();
let state = load_session(
&store,
&cli(),
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
)
.unwrap();
assert_eq!(state.approval_mode, "bypass", "the record carries the mode");
let kept =
super::super::session_loop::resume_approval_mode(&GlobalOptions::default(), "bypass")
.unwrap();
assert_eq!(kept, "bypass");
assert!(
crate::interactive::session_activation::is_bypass_mode(&state),
"the resumed session is a bypass session"
);
let line = crate::interactive::session_activation::bypass_line(
&["python3".to_owned()],
false,
false,
&[],
);
assert!(
line.contains("bypass on:") && line.contains("python3"),
"the line the resume path re-prints is the activation line: {line}"
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn an_unparseable_mode_falls_back_to_ask_never_into_bypass() {
use saya_agent::ApprovalPolicy;
assert_eq!(
ApprovalPolicy::default(),
ApprovalPolicy::Ask,
"the mode type's default is the safe direction"
);
for unknown in ["bogus", "", "bypass ", "BYPASS", "auto"] {
assert!(
unknown.parse::<ApprovalPolicy>().is_err(),
"`{unknown}` is not a mode: the parse refuses it"
);
assert_eq!(
unknown
.parse::<ApprovalPolicy>()
.unwrap_or(ApprovalPolicy::Ask),
ApprovalPolicy::Ask,
"the parse sites' fallback is ask, never bypass"
);
}
let state = super::state_from_redacted(
RedactedSession {
version: saya_store::SESSION_VERSION,
id: "skew".into(),
approval_mode: "bogus".into(),
..Default::default()
},
&SessionDefaults {
provider: "ollama".into(),
model: "m".into(),
allow_data_sharing: false,
approval_mode: "ask".into(),
},
);
assert_eq!(state.approval_mode, "bogus");
assert!(
!crate::interactive::session_activation::is_bypass_mode(&state),
"an unparseable mode is never bypass"
);
}