use agent_client_protocol::schema::v1::{
SessionConfigKind, SessionConfigOption, SessionConfigSelectOptions,
};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct ConfigPrefsHandle {
pub db: SqlitePool,
pub db_session_id: String,
pub agent_id: String,
}
pub async fn save_session_config(
db: &SqlitePool,
session_id: &str,
config_id: &str,
value: &str,
) -> Result<(), sqlx::Error> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO session_config_options (session_id, config_id, value, updated_at) \
VALUES (?, ?, ?, ?) \
ON CONFLICT(session_id, config_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(session_id)
.bind(config_id)
.bind(value)
.bind(&now)
.execute(db)
.await?;
Ok(())
}
pub async fn save_agent_pref(
db: &SqlitePool,
agent_id: &str,
config_id: &str,
value: &str,
) -> Result<(), sqlx::Error> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO agent_config_preferences (agent_id, config_id, value, updated_at) \
VALUES (?, ?, ?, ?) \
ON CONFLICT(agent_id, config_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(agent_id)
.bind(config_id)
.bind(value)
.bind(&now)
.execute(db)
.await?;
Ok(())
}
pub async fn list_session_configs(
db: &SqlitePool,
session_id: &str,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT config_id, value FROM session_config_options WHERE session_id = ?")
.bind(session_id)
.fetch_all(db)
.await?;
Ok(rows)
}
pub async fn list_agent_prefs(
db: &SqlitePool,
agent_id: &str,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT config_id, value FROM agent_config_preferences WHERE agent_id = ?")
.bind(agent_id)
.fetch_all(db)
.await?;
Ok(rows)
}
pub async fn clear_session_configs(db: &SqlitePool, session_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session_config_options WHERE session_id = ?")
.bind(session_id)
.execute(db)
.await?;
Ok(())
}
pub async fn clear_agent_prefs(db: &SqlitePool, agent_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM agent_config_preferences WHERE agent_id = ?")
.bind(agent_id)
.execute(db)
.await?;
Ok(())
}
pub fn validate_config_value(opt: &SessionConfigOption, value: &str) -> bool {
match &opt.kind {
SessionConfigKind::Boolean(_) => value == "true" || value == "false",
SessionConfigKind::Select(sel) => match &sel.options {
SessionConfigSelectOptions::Ungrouped(opts) => {
if opts.is_empty() {
return true;
}
opts.iter().any(|o| o.value.0.as_ref() == value)
}
SessionConfigSelectOptions::Grouped(groups) => {
let mut any = false;
for g in groups {
for o in &g.options {
any = true;
if o.value.0.as_ref() == value {
return true;
}
}
}
!any
}
_ => false,
},
_ => false,
}
}
pub fn merge_prefs(
agent: Vec<(String, String)>,
session: Vec<(String, String)>,
) -> Vec<(String, String)> {
let mut out = agent;
for (k, v) in session {
if let Some(existing) = out.iter_mut().find(|(ek, _)| ek == &k) {
existing.1 = v;
} else {
out.push((k, v));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v1::{
SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption,
SessionConfigSelectOptions,
};
use sqlx::sqlite::SqlitePoolOptions;
fn select_option(
opt_id: &str,
name: &str,
current: &str,
values: &[&str],
) -> SessionConfigOption {
SessionConfigOption::select(
opt_id.to_string(),
name.to_string(),
current.to_string(),
SessionConfigSelectOptions::Ungrouped(
values
.iter()
.map(|v| SessionConfigSelectOption::new(v.to_string(), v.to_string()))
.collect(),
),
)
}
fn grouped_option(
opt_id: &str,
name: &str,
current: &str,
groups: &[(&str, &[&str])],
) -> SessionConfigOption {
SessionConfigOption::select(
opt_id.to_string(),
name.to_string(),
current.to_string(),
SessionConfigSelectOptions::Grouped(
groups
.iter()
.map(|(g, values)| {
SessionConfigSelectGroup::new(
g.to_string(),
g.to_string(),
values
.iter()
.map(|v| {
SessionConfigSelectOption::new(v.to_string(), v.to_string())
})
.collect(),
)
})
.collect(),
),
)
}
#[test]
fn validate_boolean() {
let opt = SessionConfigOption::boolean("thought_level", "Thinking", false);
assert!(validate_config_value(&opt, "true"));
assert!(validate_config_value(&opt, "false"));
assert!(!validate_config_value(&opt, "yes"));
assert!(!validate_config_value(&opt, ""));
assert!(!validate_config_value(&opt, "1"));
}
#[test]
fn validate_select_ungrouped() {
let opt = select_option("model", "Model", "claude", &["claude", "gpt", "gemini"]);
assert!(validate_config_value(&opt, "claude"));
assert!(validate_config_value(&opt, "gemini"));
assert!(!validate_config_value(&opt, "llama"));
assert!(!validate_config_value(&opt, ""));
}
#[test]
fn validate_select_grouped_flat() {
let opt = grouped_option(
"model",
"Model",
"claude",
&[("Anthropic", &["claude", "opus"]), ("OpenAI", &["gpt"])],
);
assert!(validate_config_value(&opt, "claude"));
assert!(validate_config_value(&opt, "opus"));
assert!(validate_config_value(&opt, "gpt"));
assert!(!validate_config_value(&opt, "llama"));
}
#[test]
fn validate_empty_options_passes_through() {
let opt = SessionConfigOption::select(
"model",
"Model",
"claude",
SessionConfigSelectOptions::Ungrouped(Vec::new()),
);
assert!(validate_config_value(&opt, "anything"));
let opt = SessionConfigOption::select(
"model",
"Model",
"claude",
SessionConfigSelectOptions::Grouped(Vec::new()),
);
assert!(validate_config_value(&opt, "anything"));
}
#[test]
fn merge_session_overrides_agent() {
let agent = vec![
("model".to_string(), "claude".to_string()),
("mode".to_string(), "plan".to_string()),
];
let session = vec![
("model".to_string(), "gpt".to_string()),
("thought_level".to_string(), "high".to_string()),
];
let merged = merge_prefs(agent, session);
assert_eq!(merged.len(), 3);
assert_eq!(merged[0], ("model".to_string(), "gpt".to_string())); assert_eq!(merged[1], ("mode".to_string(), "plan".to_string())); assert!(merged.contains(&("thought_level".to_string(), "high".to_string()))); }
#[test]
fn merge_prefs_order_independent() {
let agent = vec![("a".to_string(), "1".to_string())];
let session = vec![("a".to_string(), "2".to_string())];
let merged = merge_prefs(agent, session);
assert_eq!(merged, vec![("a".to_string(), "2".to_string())]);
}
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("memory pool");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrations");
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO agents (id, display_name, command, args, env, created_at, updated_at) \
VALUES ('agent1', 'Test Agent', 'echo', '[]', '[]', ?, ?)",
)
.bind(&now)
.bind(&now)
.execute(&pool)
.await
.expect("agent row");
sqlx::query(
"INSERT INTO projects (id, name, path, created_at) VALUES ('p1', 'p1', '/tmp', ?)",
)
.bind(&now)
.execute(&pool)
.await
.expect("project row");
for sid in ["s1", "s2"] {
sqlx::query(
"INSERT INTO sessions (id, project_id, workspace_path, created_at, runtime_kind, agent_id) \
VALUES (?, 'p1', '/tmp', ?, 'acp', 'agent1')",
)
.bind(sid)
.bind(&now)
.execute(&pool)
.await
.expect("session row");
}
pool
}
#[tokio::test]
async fn session_config_roundtrip_and_isolation() {
let pool = test_pool().await;
save_session_config(&pool, "s1", "model", "claude").await.unwrap();
save_session_config(&pool, "s2", "model", "gpt").await.unwrap();
assert_eq!(
list_session_configs(&pool, "s1").await.unwrap(),
vec![("model".to_string(), "claude".to_string())]
);
assert_eq!(
list_session_configs(&pool, "s2").await.unwrap(),
vec![("model".to_string(), "gpt".to_string())]
);
save_session_config(&pool, "s1", "model", "opus").await.unwrap();
let rows = list_session_configs(&pool, "s1").await.unwrap();
assert_eq!(rows, vec![("model".to_string(), "opus".to_string())]);
clear_session_configs(&pool, "s1").await.unwrap();
assert!(list_session_configs(&pool, "s1").await.unwrap().is_empty());
assert_eq!(list_session_configs(&pool, "s2").await.unwrap().len(), 1);
}
#[tokio::test]
async fn agent_prefs_roundtrip_and_clear() {
let pool = test_pool().await;
save_agent_pref(&pool, "agent1", "mode", "plan").await.unwrap();
save_agent_pref(&pool, "agent1", "model", "claude").await.unwrap();
let mut rows = list_agent_prefs(&pool, "agent1").await.unwrap();
rows.sort();
assert_eq!(
rows,
vec![
("mode".to_string(), "plan".to_string()),
("model".to_string(), "claude".to_string()),
]
);
clear_agent_prefs(&pool, "agent1").await.unwrap();
assert!(list_agent_prefs(&pool, "agent1").await.unwrap().is_empty());
}
}