1use acp_utils::config_option_id::ConfigOptionId;
2use acp_utils::notifications::{AuthMethodsUpdatedParams, McpRequest};
3use agent_client_protocol::{
4 self as acp, Agent, AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse,
5 AvailableCommandsUpdate, ConfigOptionUpdate, ExtNotification, Implementation, InitializeRequest,
6 InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
7 McpCapabilities, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptResponse, ProtocolVersion,
8 SessionId, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse,
9 SetSessionModeRequest, SetSessionModeResponse,
10};
11use llm::catalog::{self, LlmModel, get_local_models};
12use llm::oauth::OAuthCredentialStore;
13use llm::types::IsoString;
14use llm::{ContentBlock, ReasoningEffort};
15use std::collections::{HashMap, HashSet};
16use std::path::Path;
17use std::sync::Arc;
18use tokio::spawn;
19use tokio::sync::oneshot;
20use tokio::sync::{Mutex, mpsc};
21use tokio::task::JoinHandle;
22use tracing::{error, info};
23
24use super::config_setting::ConfigSetting;
25use super::mappers::{map_acp_mcp_servers, replay_to_client};
26use super::model_config::{
27 ValidatedMode, build_config_options_from_modes, effective_model, mode_name_for_state_from_modes, model_exists,
28 pick_default_model, resolve_mode_from_modes, supports_prompt_audio, validated_modes_from_specs,
29};
30use super::relay::{RelayHandle, SessionCommand, spawn_relay};
31use super::session::Session;
32use super::session_store::{SessionMeta, SessionStore};
33use acp_utils::content::format_embedded_resource;
34use acp_utils::server::AcpActorHandle;
35use aether_core::context::ext::ContextExt;
36use aether_project::{AgentCatalog, load_agent_catalog};
37use llm::Context;
38
39struct SessionModeCatalog {
40 catalog: AgentCatalog,
41 modes: Vec<ValidatedMode>,
42}
43
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45struct PromptModalities {
46 image: bool,
47 audio: bool,
48}
49
50impl PromptModalities {
51 fn from_content(content: &[ContentBlock]) -> Self {
52 Self {
53 image: content.iter().any(ContentBlock::is_image),
54 audio: content.iter().any(|block| matches!(block, ContentBlock::Audio { .. })),
55 }
56 }
57
58 fn is_empty(self) -> bool {
59 !self.image && !self.audio
60 }
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
65struct SessionConfigState {
66 active_model: String,
67 pending_model: Option<String>,
68 reasoning_effort: Option<ReasoningEffort>,
69 selected_mode: Option<String>,
70}
71
72impl SessionConfigState {
73 fn new(active_model: String) -> Self {
74 Self { active_model, pending_model: None, reasoning_effort: None, selected_mode: None }
75 }
76
77 fn apply_config_change(
78 &mut self,
79 validated_modes: &[ValidatedMode],
80 available: &[LlmModel],
81 setting: &ConfigSetting,
82 ) -> Result<(), acp::Error> {
83 match setting {
84 ConfigSetting::Mode(value) => {
85 let Some((mode_model, mode_reasoning_effort)) = resolve_mode_from_modes(validated_modes, value) else {
86 error!("Unknown or invalid mode: {}", value);
87 return Err(acp::Error::invalid_params());
88 };
89
90 self.pending_model = (self.active_model != mode_model).then_some(mode_model);
91 self.reasoning_effort = mode_reasoning_effort;
92 self.selected_mode = Some(value.clone());
93 }
94 ConfigSetting::Model(value) => {
95 if !model_exists(available, value) {
96 error!("Unknown model in set_session_config_option: {}", value);
97 return Err(acp::Error::invalid_params());
98 }
99 self.pending_model = (self.active_model != *value).then_some(value.clone());
100 }
101 ConfigSetting::ReasoningEffort(effort) => {
102 self.reasoning_effort = *effort;
103 }
104 }
105
106 let effective = effective_model(&self.active_model, self.pending_model.as_deref());
107 if setting.config_id() == ConfigOptionId::Model {
108 self.selected_mode = mode_name_for_state_from_modes(validated_modes, effective, self.reasoning_effort);
109 }
110
111 Ok(())
112 }
113}
114
115struct SessionState {
117 relay_tx: mpsc::Sender<SessionCommand>,
118 mcp_request_tx: mpsc::Sender<McpRequest>,
119 #[allow(dead_code)]
120 _relay_handle: JoinHandle<()>,
121 config: SessionConfigState,
122 modes: Vec<ValidatedMode>,
123}
124
125pub struct SessionManager {
127 sessions: Arc<Mutex<HashMap<String, SessionState>>>,
128 actor_handle: AcpActorHandle,
129 session_store: Arc<SessionStore>,
130 has_oauth_credential: fn(&str) -> bool,
131}
132
133impl SessionManager {
134 pub fn new(actor_handle: AcpActorHandle) -> Self {
135 info!("Creating SessionManager");
136 let session_store =
137 SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
138 Self {
139 sessions: Arc::new(Mutex::new(HashMap::new())),
140 actor_handle,
141 session_store,
142 has_oauth_credential: OAuthCredentialStore::has_credential,
143 }
144 }
145
146 async fn load_mode_catalog(cwd: &Path) -> Result<SessionModeCatalog, acp::Error> {
147 let catalog = load_agent_catalog(cwd).map_err(|e| {
148 error!("Failed to load agent catalog: {e}");
149 acp::Error::invalid_params()
150 })?;
151
152 let available = get_local_models().await;
153 let specs: Vec<_> = catalog.user_invocable().cloned().collect();
154 let modes = validated_modes_from_specs(&specs, &available);
155
156 Ok(SessionModeCatalog { catalog, modes })
157 }
158
159 #[allow(clippy::too_many_arguments, clippy::similar_names)]
160 async fn register_session(
161 &self,
162 session: Session,
163 session_id: &str,
164 acp_session_id: &SessionId,
165 model: &str,
166 selected_mode: Option<String>,
167 reasoning_effort: Option<ReasoningEffort>,
168 modes: Vec<ValidatedMode>,
169 ) -> Vec<acp::SessionConfigOption> {
170 let RelayHandle { cmd_tx, mcp_request_tx, join_handle } =
171 spawn_relay(session, self.actor_handle.clone(), acp_session_id.clone(), self.session_store.clone());
172
173 let mut config = SessionConfigState::new(model.to_string());
174 config.reasoning_effort = reasoning_effort;
175 config.selected_mode.clone_from(&selected_mode);
176
177 let state =
178 SessionState { relay_tx: cmd_tx, mcp_request_tx, _relay_handle: join_handle, config, modes: modes.clone() };
179
180 let mut sessions = self.sessions.lock().await;
181 sessions.insert(session_id.to_string(), state);
182
183 let available = get_local_models().await;
184 let all_models = get_all_models(&available);
185 build_config_options_from_modes(
186 &modes,
187 &available,
188 selected_mode.as_deref(),
189 model,
190 reasoning_effort,
191 &all_models,
192 &OAuthCredentialStore::default(),
193 )
194 }
195
196 fn spawn_available_commands_notification(
197 &self,
198 available_commands: Vec<acp::AvailableCommand>,
199 acp_session_id: SessionId,
200 session_id: &str,
201 ) {
202 if available_commands.is_empty() {
203 return;
204 }
205 let command_count = available_commands.len();
206 let notification = SessionNotification::new(
207 acp_session_id,
208 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(available_commands)),
209 );
210 let actor_handle = self.actor_handle.clone();
211 let session_id_log = session_id.to_string();
212 spawn(async move {
213 if let Err(e) = actor_handle.send_session_notification(notification).await {
214 error!("Failed to send available commands notification: {:?}", e);
215 } else {
216 info!("Sent available commands update for session {} ({} commands)", session_id_log, command_count);
217 }
218 });
219 }
220}
221
222fn get_all_models(discovered: &[LlmModel]) -> Vec<LlmModel> {
225 let mut all = LlmModel::all().to_vec();
226 for m in discovered {
227 if !all.contains(m) {
228 all.push(m.clone());
229 }
230 }
231 all
232}
233
234fn build_auth_methods(has_credential: impl Fn(&str) -> bool) -> Vec<AuthMethod> {
235 let mut seen = HashSet::new();
236 LlmModel::all()
237 .iter()
238 .filter_map(LlmModel::oauth_provider_id)
239 .filter(|id| seen.insert(*id))
240 .map(|id| {
241 let display = LlmModel::all()
242 .iter()
243 .find(|m| m.oauth_provider_id() == Some(id))
244 .map_or(id, |m| m.provider_display_name());
245 let mut method = acp::AuthMethodAgent::new(id, display);
246 if has_credential(id) {
247 method = method.description("authenticated");
248 }
249 AuthMethod::Agent(method)
250 })
251 .collect()
252}
253
254fn map_acp_to_content_blocks(blocks: Vec<acp::ContentBlock>) -> Vec<ContentBlock> {
255 blocks
256 .into_iter()
257 .map(|block| match block {
258 acp::ContentBlock::Text(t) => ContentBlock::text(t.text),
259 acp::ContentBlock::Image(img) => ContentBlock::Image { data: img.data, mime_type: img.mime_type },
260 acp::ContentBlock::Audio(aud) => ContentBlock::Audio { data: aud.data, mime_type: aud.mime_type },
261 acp::ContentBlock::Resource(r) => ContentBlock::text(format_embedded_resource(&r)),
262 acp::ContentBlock::ResourceLink(l) => ContentBlock::text(format!("[Resource: {}]", l.uri)),
263 _ => ContentBlock::text("[Unknown content]"),
264 })
265 .collect()
266}
267
268fn select_initial_mode(
269 validated_modes: &[ValidatedMode],
270) -> (Option<String>, Option<(String, Option<ReasoningEffort>)>) {
271 validated_modes
272 .first()
273 .map_or((None, None), |mode| (Some(mode.name.clone()), Some((mode.model.clone(), mode.reasoning_effort))))
274}
275
276fn prompt_capabilities_for_models(models: &[LlmModel]) -> PromptCapabilities {
277 PromptCapabilities::new()
278 .embedded_context(true)
279 .image(models.iter().any(LlmModel::supports_image))
280 .audio(models.iter().any(supports_prompt_audio))
281}
282
283fn selected_models(model_value: &str) -> Result<Vec<LlmModel>, acp::Error> {
284 model_value
285 .split(',')
286 .map(str::trim)
287 .filter(|part| !part.is_empty())
288 .map(|part| part.parse::<LlmModel>().map_err(|_| acp::Error::invalid_params()))
289 .collect()
290}
291
292fn validate_prompt_support(model_value: &str, content: &[ContentBlock]) -> Result<(), acp::Error> {
293 let modalities = PromptModalities::from_content(content);
294 if modalities.is_empty() {
295 return Ok(());
296 }
297
298 let selected = selected_models(model_value)?;
299 if modalities.image && selected.iter().any(|model| !model.supports_image()) {
300 return Err(acp::Error::invalid_params());
301 }
302 if modalities.audio && selected.iter().any(|model| !supports_prompt_audio(model)) {
303 return Err(acp::Error::invalid_params());
304 }
305
306 Ok(())
307}
308
309#[cfg(test)]
310#[allow(clippy::items_after_test_module)]
311mod tests {
312 use super::*;
313 use crate::acp::config_setting::ConfigSetting;
314 use ReasoningEffort as RE;
315 use agent_client_protocol::{InitializeRequest, ProtocolVersion};
316
317 const SONNET: &str = "anthropic:claude-sonnet-4-5";
318 const DEEPSEEK: &str = "deepseek:deepseek-chat";
319
320 fn available_models() -> Vec<LlmModel> {
321 [SONNET, "anthropic:claude-opus-4-6", DEEPSEEK].into_iter().map(|s| s.parse().expect("valid model")).collect()
322 }
323
324 fn validated_modes() -> Vec<ValidatedMode> {
325 let m = |name: &str, model: &str, effort| ValidatedMode {
326 name: name.into(),
327 model: model.into(),
328 reasoning_effort: effort,
329 };
330 vec![m("Planner", SONNET, Some(RE::High)), m("Coder", DEEPSEEK, None)]
331 }
332
333 fn apply(
334 active: &str,
335 effort: Option<RE>,
336 mode: Option<&str>,
337 setting: &ConfigSetting,
338 ) -> (Result<(), acp::Error>, SessionConfigState) {
339 let mut state = SessionConfigState::new(active.into());
340 state.reasoning_effort = effort;
341 state.selected_mode = mode.map(Into::into);
342 let result = state.apply_config_change(&validated_modes(), &available_models(), setting);
343 (result, state)
344 }
345
346 #[test]
347 fn new_state_has_no_pending_model_or_mode() {
348 let s = SessionConfigState::new(DEEPSEEK.into());
349 assert!((s.pending_model.is_none() && s.reasoning_effort.is_none() && s.selected_mode.is_none()));
350 }
351
352 #[test]
353 fn mode_selection_updates_pending_model_and_reasoning() {
354 let (res, s) = apply(DEEPSEEK, None, None, &ConfigSetting::Mode("Planner".into()));
355 assert!(res.is_ok());
356 assert_eq!(s.pending_model.as_deref(), Some(SONNET));
357 assert_eq!(s.reasoning_effort, Some(RE::High));
358 assert_eq!(s.selected_mode.as_deref(), Some("Planner"));
359 }
360
361 #[test]
362 fn unknown_mode_is_rejected() {
363 let (res, _) = apply(DEEPSEEK, None, None, &ConfigSetting::Mode("Unknown".into()));
364 assert!(res.is_err());
365 }
366
367 #[test]
368 fn effort_change_preserves_mode_and_model_change_clears_it() {
369 let (res, s) = apply(SONNET, Some(RE::High), Some("Planner"), &ConfigSetting::ReasoningEffort(Some(RE::Low)));
371 assert!(res.is_ok());
372 assert_eq!(s.reasoning_effort, Some(RE::Low));
373 assert_eq!(s.selected_mode.as_deref(), Some("Planner"));
374
375 let (res, s) = apply(SONNET, Some(RE::Medium), Some("Planner"), &ConfigSetting::Model(DEEPSEEK.into()));
377 assert!(res.is_ok());
378 assert_eq!(s.pending_model.as_deref(), Some(DEEPSEEK));
379 assert!(s.selected_mode.is_none());
380 }
381
382 #[tokio::test]
383 async fn initialize_always_advertises_load_session_support() {
384 let (tx, _rx) = mpsc::unbounded_channel();
385 let mut manager = SessionManager::new(AcpActorHandle::new(tx));
386 manager.has_oauth_credential = |_| false;
387 let response =
388 manager.initialize(InitializeRequest::new(ProtocolVersion::LATEST)).await.expect("initialize succeeds");
389 let json = serde_json::to_string(&response).expect("response serializes");
390 assert!(json.contains("\"loadSession\":true"));
391 }
392
393 #[test]
394 fn prompt_capabilities_reflect_available_modalities() {
395 let image_only = prompt_capabilities_for_models(&["anthropic:claude-sonnet-4-5".parse().unwrap()]);
396 assert!(image_only.image);
397 assert!(!image_only.audio);
398
399 let audio_capable =
400 prompt_capabilities_for_models(&["gemini:gemini-live-2.5-flash-preview-native-audio".parse().unwrap()]);
401 assert!(!audio_capable.image);
402 assert!(audio_capable.audio);
403
404 let text_only = prompt_capabilities_for_models(&[DEEPSEEK.parse().unwrap()]);
405 assert!(!text_only.image);
406 assert!(!text_only.audio);
407 }
408
409 #[test]
410 fn validate_prompt_support_requires_all_selected_models_to_support_media() {
411 let image_content = vec![ContentBlock::Image { data: "aW1n".to_string(), mime_type: "image/png".to_string() }];
412 let audio_content =
413 vec![ContentBlock::Audio { data: "YXVkaW8=".to_string(), mime_type: "audio/wav".to_string() }];
414
415 assert!(validate_prompt_support(SONNET, &image_content).is_ok());
416 assert!(validate_prompt_support(DEEPSEEK, &image_content).is_err());
417 assert!(validate_prompt_support("gemini:gemini-live-2.5-flash-preview-native-audio", &audio_content,).is_ok());
418 assert!(validate_prompt_support(SONNET, &audio_content).is_err());
419 assert!(
420 validate_prompt_support("anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", &image_content,).is_err()
421 );
422 assert!(
423 validate_prompt_support(
424 "gemini:gemini-live-2.5-flash-preview-native-audio,deepseek:deepseek-chat",
425 &audio_content,
426 )
427 .is_err()
428 );
429 }
430}
431
432#[async_trait::async_trait(?Send)]
433impl Agent for SessionManager {
434 async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse, acp::Error> {
435 info!("Received initialize request: {:?}", args);
436 let auth_methods = build_auth_methods(self.has_oauth_credential);
437 let available = get_local_models().await;
438 Ok(InitializeResponse::new(ProtocolVersion::V1)
439 .agent_info(Implementation::new("Aether", "0.1.0"))
440 .agent_capabilities(
441 AgentCapabilities::new()
442 .load_session(true)
443 .mcp_capabilities(McpCapabilities::new().http(true).sse(true))
444 .session_capabilities(acp::SessionCapabilities::new().list(acp::SessionListCapabilities::new()))
445 .prompt_capabilities(prompt_capabilities_for_models(&available)),
446 )
447 .auth_methods(auth_methods))
448 }
449
450 async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse, acp::Error> {
451 info!("Received authenticate request: {:?}", args);
452 let method_id = args.method_id.0.as_ref();
453 match method_id {
454 "codex" => {
455 llm::perform_codex_oauth_flow().await.map_err(|e| {
456 error!("OAuth flow failed for {method_id}: {e}");
457 acp::Error::internal_error()
458 })?;
459 }
460 _ => return Err(acp::Error::invalid_params()),
461 }
462 let auth_methods = build_auth_methods(self.has_oauth_credential);
463 let auth_methods_notification: ExtNotification = AuthMethodsUpdatedParams { auth_methods }.into();
464 if let Err(e) = self.actor_handle.send_ext_notification(auth_methods_notification).await {
465 error!("Failed to send auth methods updated notification: {:?}", e);
466 }
467
468 let credential_store = OAuthCredentialStore::default();
470 let available = catalog::get_local_models().await;
471 let all_models = get_all_models(&available);
472 let sessions = self.sessions.lock().await;
473 for (id, state) in sessions.iter() {
474 let model = effective_model(&state.config.active_model, state.config.pending_model.as_deref());
475 let options = build_config_options_from_modes(
476 &state.modes,
477 &available,
478 state.config.selected_mode.as_deref(),
479 model,
480 state.config.reasoning_effort,
481 &all_models,
482 &credential_store,
483 );
484 let notification = SessionNotification::new(
485 SessionId::new(id.clone()),
486 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
487 );
488 let _ = self.actor_handle.send_session_notification(notification).await;
489 }
490
491 Ok(AuthenticateResponse::default())
492 }
493
494 async fn new_session(&self, mut args: NewSessionRequest) -> Result<NewSessionResponse, acp::Error> {
495 if std::env::var("AETHER_INSIDE_SANDBOX").is_ok() {
498 let container_cwd = std::env::current_dir().unwrap_or_else(|_| "/workspace".into());
499 info!("Sandbox: remapping cwd {:?} -> {:?}", args.cwd, container_cwd);
500 args.cwd = container_cwd;
501 }
502
503 info!("Creating new session with cwd: {:?}", args.cwd);
504 let session_id = uuid::Uuid::new_v4().to_string();
505 let acp_session_id = acp::SessionId::new(session_id.clone());
506
507 let mode_catalog = Self::load_mode_catalog(&args.cwd).await?;
508 let available = catalog::get_local_models().await;
509 let default_model = pick_default_model(&available).ok_or_else(|| {
510 error!("No models available — set an API key env var (e.g. ANTHROPIC_API_KEY)");
511 acp::Error::internal_error()
512 })?;
513
514 let (initial_selected_mode, mode_config) = select_initial_mode(&mode_catalog.modes);
515
516 let spec = if let Some(selected_mode) = initial_selected_mode.as_deref() {
517 mode_catalog.catalog.resolve(selected_mode, &args.cwd).map_err(|e| {
518 error!("Failed to resolve runtime inputs for mode '{}': {e}", selected_mode);
519 acp::Error::invalid_params()
520 })?
521 } else {
522 mode_catalog.catalog.resolve_default(default_model, None, &args.cwd)
523 };
524
525 let model_str = spec.model.clone();
526 let initial_reasoning_effort = mode_config.and_then(|(_, effort)| effort);
527
528 let session =
529 Session::new(spec, args.cwd.clone(), map_acp_mcp_servers(args.mcp_servers), None, Some(session_id.clone()))
530 .await
531 .map_err(|e| {
532 error!("Failed to create session: {}", e);
533 acp::Error::internal_error()
534 })?;
535
536 let available_commands = session.list_available_commands().await.map_err(|e| {
537 error!("Failed to list available commands: {}", e);
538 acp::Error::internal_error()
539 })?;
540
541 let meta = SessionMeta {
542 session_id: session_id.clone(),
543 cwd: args.cwd.clone(),
544 model: model_str.clone(),
545 selected_mode: initial_selected_mode.clone(),
546 created_at: IsoString::now().0,
547 };
548 if let Err(e) = self.session_store.append_meta(&session_id, &meta) {
549 error!("Failed to write session meta: {e}");
550 }
551
552 let config_options = self
553 .register_session(
554 session,
555 &session_id,
556 &acp_session_id,
557 &model_str,
558 initial_selected_mode,
559 initial_reasoning_effort,
560 mode_catalog.modes,
561 )
562 .await;
563
564 info!("Session {} created successfully", session_id);
565
566 let response = NewSessionResponse::new(acp_session_id.clone()).config_options(config_options);
567
568 self.spawn_available_commands_notification(available_commands, acp_session_id, &session_id);
569
570 Ok(response)
571 }
572
573 async fn list_sessions(&self, args: ListSessionsRequest) -> Result<ListSessionsResponse, acp::Error> {
574 info!("Listing sessions, cwd filter: {:?}", args.cwd);
575 let mut summaries = self.session_store.list();
576
577 if let Some(ref cwd) = args.cwd {
578 summaries.retain(|s| s.meta.cwd == *cwd);
579 }
580
581 let sessions: Vec<acp::SessionInfo> = summaries
582 .into_iter()
583 .map(|s| acp::SessionInfo::new(s.meta.session_id, s.meta.cwd).updated_at(s.meta.created_at).title(s.title))
584 .collect();
585
586 info!("Found {} sessions", sessions.len());
587 Ok(ListSessionsResponse::new(sessions))
588 }
589
590 async fn load_session(&self, args: LoadSessionRequest) -> Result<LoadSessionResponse, acp::Error> {
591 let session_id = args.session_id.0.to_string();
592 info!("Loading session: {session_id}");
593
594 let (meta, events) = self.session_store.load(&session_id).ok_or_else(|| {
595 error!("Session not found: {session_id}");
596 acp::Error::invalid_params()
597 })?;
598
599 let context = Context::from_events(&events);
600 let mode_catalog = Self::load_mode_catalog(&args.cwd).await?;
601
602 let spec = if let Some(mode_name) = meta.selected_mode.as_deref() {
603 mode_catalog.catalog.resolve(mode_name, &args.cwd).map_err(|e| {
604 error!("Failed to resolve runtime inputs for mode '{}': {e}", mode_name);
605 acp::Error::invalid_params()
606 })?
607 } else {
608 let parsed_model: LlmModel = meta.model.parse().map_err(|e: String| {
609 error!("Failed to parse restored model '{}': {e}", meta.model);
610 acp::Error::invalid_params()
611 })?;
612 mode_catalog.catalog.resolve_default(&parsed_model, None, &args.cwd)
613 };
614
615 let model = spec.model.clone();
616
617 let restored_messages: Vec<_> = context.messages().iter().filter(|m| !m.is_system()).cloned().collect();
618
619 let session = Session::new(
620 spec,
621 args.cwd.clone(),
622 map_acp_mcp_servers(args.mcp_servers),
623 Some(restored_messages),
624 Some(session_id.clone()),
625 )
626 .await
627 .map_err(|e| {
628 error!("Failed to create session for load: {e}");
629 acp::Error::internal_error()
630 })?;
631
632 let available_commands = session.list_available_commands().await.map_err(|e| {
633 error!("Failed to list available commands: {e}");
634 acp::Error::internal_error()
635 })?;
636
637 let acp_session_id = acp::SessionId::new(session_id.clone());
638
639 let config_options = self
640 .register_session(
641 session,
642 &session_id,
643 &acp_session_id,
644 &model,
645 meta.selected_mode,
646 None,
647 mode_catalog.modes,
648 )
649 .await;
650
651 info!("Session {session_id} loaded successfully");
652
653 let response = LoadSessionResponse::new().config_options(config_options);
654
655 let actor_handle = self.actor_handle.clone();
656 let replay_session_id = acp_session_id.clone();
657 spawn(async move {
658 replay_to_client(&events, &actor_handle, &replay_session_id).await;
659 });
660
661 self.spawn_available_commands_notification(available_commands, acp_session_id, &session_id);
662
663 Ok(response)
664 }
665
666 async fn prompt(&self, args: acp::PromptRequest) -> Result<acp::PromptResponse, acp::Error> {
667 info!("Received prompt for session: {:?}", args.session_id);
668 let session_id_str = args.session_id.0.to_string();
669
670 let (relay_tx, content, switch_model, reasoning_effort) = {
671 let mut sessions = self.sessions.lock().await;
672 let state = sessions.get_mut(&session_id_str).ok_or_else(|| {
673 error!("Session not found: {}", session_id_str);
674 acp::Error::invalid_params()
675 })?;
676
677 let content = map_acp_to_content_blocks(args.prompt);
678 let effective_model = effective_model(&state.config.active_model, state.config.pending_model.as_deref());
679 validate_prompt_support(effective_model, &content)?;
680
681 let switch_model = state.config.pending_model.take().and_then(|pending| {
682 if pending == state.config.active_model {
683 None
684 } else {
685 state.config.active_model.clone_from(&pending);
686 Some(pending)
687 }
688 });
689
690 let reasoning_effort = state.config.reasoning_effort;
691
692 (state.relay_tx.clone(), content, switch_model, reasoning_effort)
693 };
694
695 let (result_tx, result_rx) = oneshot::channel();
696 relay_tx.send(SessionCommand::Prompt { content, switch_model, reasoning_effort, result_tx }).await.map_err(
697 |_| {
698 error!("Relay channel closed for session {}", session_id_str);
699 acp::Error::internal_error()
700 },
701 )?;
702
703 let stop_reason = result_rx
704 .await
705 .map_err(|_| {
706 error!("Relay dropped result channel for session {}", session_id_str);
707 acp::Error::internal_error()
708 })?
709 .map_err(|e| {
710 error!("Relay error for session {}: {}", session_id_str, e);
711 acp::Error::internal_error()
712 })?;
713
714 info!("Prompt completed with stop reason: {:?}", stop_reason);
715 Ok(PromptResponse::new(stop_reason))
716 }
717
718 async fn cancel(&self, args: acp::CancelNotification) -> Result<(), acp::Error> {
719 info!("Received cancel for session: {:?}", args.session_id);
720 let session_id_str = args.session_id.0.to_string();
721 let relay_tx = {
722 let sessions = self.sessions.lock().await;
723 sessions
724 .get(&session_id_str)
725 .ok_or_else(|| {
726 error!("Session not found for cancel: {}", session_id_str);
727 acp::Error::invalid_params()
728 })?
729 .relay_tx
730 .clone()
731 };
732
733 relay_tx.send(SessionCommand::Cancel).await.map_err(|_| {
734 error!("Relay channel closed for cancel: {}", session_id_str);
735 acp::Error::internal_error()
736 })?;
737
738 Ok(())
739 }
740
741 async fn set_session_config_option(
742 &self,
743 args: SetSessionConfigOptionRequest,
744 ) -> Result<SetSessionConfigOptionResponse, acp::Error> {
745 let session_id_str = args.session_id.0.to_string();
746 let config_id = args.config_id.0.to_string();
747 let value = args.value.0.to_string();
748
749 info!("set_session_config_option: session={}, config={}, value={}", session_id_str, config_id, value);
750
751 let setting = ConfigSetting::parse(&config_id, &value).map_err(|e| {
752 error!("{e}");
753 acp::Error::invalid_params()
754 })?;
755
756 let available = get_local_models().await;
757 let all_models = get_all_models(&available);
758
759 let mut sessions = self.sessions.lock().await;
760 let state = sessions.get_mut(&session_id_str).ok_or_else(|| {
761 error!("Session not found: {}", session_id_str);
762 acp::Error::invalid_params()
763 })?;
764
765 state.config.apply_config_change(&state.modes, &available, &setting)?;
766
767 let effective_model = effective_model(&state.config.active_model, state.config.pending_model.as_deref());
768 let options = build_config_options_from_modes(
769 &state.modes,
770 &available,
771 state.config.selected_mode.as_deref(),
772 effective_model,
773 state.config.reasoning_effort,
774 &all_models,
775 &OAuthCredentialStore::default(),
776 );
777 Ok(SetSessionConfigOptionResponse::new(options))
778 }
779
780 async fn set_session_mode(&self, args: SetSessionModeRequest) -> Result<SetSessionModeResponse, acp::Error> {
781 info!("Received set_session_mode request: {:?}", args);
782 Err(acp::Error::method_not_found())
783 }
784
785 async fn ext_method(&self, args: acp::ExtRequest) -> Result<acp::ExtResponse, acp::Error> {
786 info!("Received extension method: {}, params: {:?}", args.method, args.params);
787 let null_value: Arc<serde_json::value::RawValue> = serde_json::from_str("null").expect("null is valid JSON");
788 Ok(null_value.into())
789 }
790
791 async fn ext_notification(&self, args: acp::ExtNotification) -> Result<(), acp::Error> {
792 info!("Received extension notification: {}, params: {:?}", args.method, args.params);
793
794 if let Ok(msg) = McpRequest::try_from(&args) {
795 match msg {
796 McpRequest::Authenticate { session_id, server_name } => {
797 let mcp_request_tx = {
798 let sessions = self.sessions.lock().await;
799 let session = sessions.get(&session_id);
800 session
801 .ok_or_else(|| {
802 error!("Session not found for authenticate_mcp_server: {}", session_id);
803 acp::Error::invalid_params()
804 })?
805 .mcp_request_tx
806 .clone()
807 };
808
809 mcp_request_tx.send(McpRequest::Authenticate { session_id, server_name }).await.map_err(|_| {
810 error!("MCP request channel closed for session");
811 acp::Error::internal_error()
812 })?;
813 }
814 }
815 }
816
817 Ok(())
818 }
819}