1pub(crate) mod config_setting;
2pub(crate) mod handlers;
3pub(crate) mod mappers;
4pub(crate) mod model_config;
5pub(crate) mod relay;
6pub(crate) mod session;
7pub(crate) mod session_manager;
8pub(crate) mod session_registry;
9pub(crate) mod session_store;
10pub(crate) mod stdio;
11pub mod testing;
12
13pub use mappers::map_mcp_prompt_to_available_command;
14pub use session_manager::SessionManager;
15
16use crate::acp::handlers::acp_agent_builder;
17use crate::acp::stdio::Stdio;
18use crate::provider_connection_args::ProviderConnectionArgs;
19use crate::settings_args::SettingsSourceArgs;
20use agent_client_protocol as acp;
21use llm::ReasoningEffort;
22use std::sync::Arc;
23use std::{fs::create_dir_all, path::PathBuf};
24use thiserror::Error;
25use tracing::info;
26use tracing_appender::rolling::daily;
27use tracing_subscriber::EnvFilter;
28
29use aether_auth::OAuthCredentialStorage;
30use session_manager::{InitialSessionSelection, SessionManagerConfig};
31use session_registry::SessionRegistry;
32use session_store::SessionStore;
33
34#[derive(clap::Args, Debug)]
35pub struct AcpArgs {
36 #[clap(long, default_value = "/tmp/aether-acp-logs")]
38 pub log_dir: PathBuf,
39
40 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
42 pub agent: Option<String>,
43
44 #[clap(long, conflicts_with = "agent")]
47 pub model: Option<String>,
48
49 #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
51 pub reasoning_effort: Option<ReasoningEffort>,
52
53 #[command(flatten)]
54 pub provider_connection: ProviderConnectionArgs,
55
56 #[command(flatten)]
57 pub settings_source: SettingsSourceArgs,
58}
59
60#[derive(Debug)]
62pub enum AcpRunOutcome {
63 CleanDisconnect,
65}
66
67#[derive(Debug, Error)]
69pub enum AcpRunError {
70 #[error("ACP protocol error: {0}")]
71 Protocol(#[from] acp::Error),
72}
73
74pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
75 info!("Starting Aether ACP server");
76
77 setup_logging(&args);
78
79 let initial_selection = if let Some(agent) = args.agent.clone() {
80 InitialSessionSelection::agent(agent)
81 } else if let Some(model) = args.model.clone() {
82 InitialSessionSelection::model(model, args.reasoning_effort)
83 } else {
84 InitialSessionSelection::default()
85 };
86 let session_store =
87 SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
88 let oauth_credential_store: Arc<dyn OAuthCredentialStorage> =
89 Arc::new(aether_auth::OsKeyringStore::with_platform_store());
90 let provider_connections = args.provider_connection.into_overrides();
91 let manager = Arc::new(SessionManager::new(SessionManagerConfig {
92 registry: Arc::new(SessionRegistry::new()),
93 session_store,
94 oauth_credential_store,
95 initial_selection,
96 settings_source: args.settings_source,
97 provider_connections,
98 }));
99
100 let connect_result = acp_agent_builder(manager.clone()).connect_to(Stdio::new()).await;
101 manager.shutdown_all_sessions().await;
102
103 match connect_result {
104 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
105 Err(err) => Err(AcpRunError::Protocol(err)),
106 }
107}
108
109fn setup_logging(args: &AcpArgs) {
110 create_dir_all(&args.log_dir).ok();
111 tracing_subscriber::fmt()
112 .with_writer(daily(&args.log_dir, "aether-acp.log"))
113 .with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
115 .pretty()
116 .init();
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use clap::Parser;
123
124 #[derive(Debug, Parser)]
125 struct TestCli {
126 #[command(flatten)]
127 args: AcpArgs,
128 }
129
130 #[test]
131 fn agent_conflicts_with_model() {
132 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
133 .expect_err("agent and model should conflict");
134 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
135 }
136
137 #[test]
138 fn agent_conflicts_with_reasoning_effort() {
139 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
140 .expect_err("agent and reasoning effort should conflict");
141 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
142 }
143
144 #[test]
145 fn reasoning_effort_requires_model() {
146 let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
147 .expect_err("reasoning effort should require model");
148 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
149 }
150
151 #[test]
152 fn reasoning_effort_with_model_is_allowed() {
153 let cli =
154 TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
155 .expect("reasoning effort can configure an explicit model session");
156 assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
157 }
158}