Skip to main content

aether_cli/acp/
session_manager.rs

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/// Mutable per-session config state.
64#[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
115/// Per-session state including active and staged model selections.
116struct 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
125/// Manages ACP sessions, each session has its own agent and state
126pub 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
222/// Merge catalog `all()` with locally-discovered models for the `all_models`
223/// parameter of `build_model_config_option`.
224fn 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        // Changing reasoning effort keeps the selected mode.
370        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        // Changing the model to one that doesn't match any mode clears the mode.
376        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        // Broadcast updated config options to all active sessions
469        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        // Inside a sandbox container the client sends the *host* cwd, but the
496        // project is mounted at the container's working directory.
497        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}