Skip to main content

aether_cli/acp/
session_manager.rs

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