connection.rs

  1use crate::AcpThread;
  2use agent_client_protocol::{self as acp};
  3use anyhow::Result;
  4use chrono::{DateTime, Utc};
  5use collections::IndexMap;
  6use gpui::{Entity, SharedString, Task};
  7use language_model::LanguageModelProviderId;
  8use project::Project;
  9use serde::{Deserialize, Serialize};
 10use std::{
 11    any::Any,
 12    error::Error,
 13    fmt,
 14    path::{Path, PathBuf},
 15    rc::Rc,
 16    sync::Arc,
 17};
 18use ui::{App, IconName};
 19use uuid::Uuid;
 20
 21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
 22pub struct UserMessageId(Arc<str>);
 23
 24impl UserMessageId {
 25    pub fn new() -> Self {
 26        Self(Uuid::new_v4().to_string().into())
 27    }
 28}
 29
 30pub trait AgentConnection {
 31    fn telemetry_id(&self) -> SharedString;
 32
 33    fn new_session(
 34        self: Rc<Self>,
 35        project: Entity<Project>,
 36        cwd: &Path,
 37        cx: &mut App,
 38    ) -> Task<Result<Entity<AcpThread>>>;
 39
 40    /// Whether this agent supports loading existing sessions.
 41    fn supports_load_session(&self, _cx: &App) -> bool {
 42        false
 43    }
 44
 45    /// Load an existing session by ID.
 46    fn load_session(
 47        self: Rc<Self>,
 48        _session: AgentSessionInfo,
 49        _project: Entity<Project>,
 50        _cwd: &Path,
 51        _cx: &mut App,
 52    ) -> Task<Result<Entity<AcpThread>>> {
 53        Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported")))
 54    }
 55
 56    /// Whether this agent supports closing existing sessions.
 57    fn supports_close_session(&self, _cx: &App) -> bool {
 58        false
 59    }
 60
 61    /// Close an existing session. Allows the agent to free the session from memory.
 62    fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
 63        Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported")))
 64    }
 65
 66    /// Whether this agent supports resuming existing sessions without loading history.
 67    fn supports_resume_session(&self, _cx: &App) -> bool {
 68        false
 69    }
 70
 71    /// Resume an existing session by ID without replaying previous messages.
 72    fn resume_session(
 73        self: Rc<Self>,
 74        _session: AgentSessionInfo,
 75        _project: Entity<Project>,
 76        _cwd: &Path,
 77        _cx: &mut App,
 78    ) -> Task<Result<Entity<AcpThread>>> {
 79        Task::ready(Err(anyhow::Error::msg(
 80            "Resuming sessions is not supported",
 81        )))
 82    }
 83
 84    /// Whether this agent supports showing session history.
 85    fn supports_session_history(&self, cx: &App) -> bool {
 86        self.supports_load_session(cx) || self.supports_resume_session(cx)
 87    }
 88
 89    fn auth_methods(&self) -> &[acp::AuthMethod];
 90
 91    fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
 92
 93    fn prompt(
 94        &self,
 95        user_message_id: Option<UserMessageId>,
 96        params: acp::PromptRequest,
 97        cx: &mut App,
 98    ) -> Task<Result<acp::PromptResponse>>;
 99
100    fn retry(&self, _session_id: &acp::SessionId, _cx: &App) -> Option<Rc<dyn AgentSessionRetry>> {
101        None
102    }
103
104    fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
105
106    fn truncate(
107        &self,
108        _session_id: &acp::SessionId,
109        _cx: &App,
110    ) -> Option<Rc<dyn AgentSessionTruncate>> {
111        None
112    }
113
114    fn set_title(
115        &self,
116        _session_id: &acp::SessionId,
117        _cx: &App,
118    ) -> Option<Rc<dyn AgentSessionSetTitle>> {
119        None
120    }
121
122    /// Returns this agent as an [Rc<dyn ModelSelector>] if the model selection capability is supported.
123    ///
124    /// If the agent does not support model selection, returns [None].
125    /// This allows sharing the selector in UI components.
126    fn model_selector(&self, _session_id: &acp::SessionId) -> Option<Rc<dyn AgentModelSelector>> {
127        None
128    }
129
130    fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
131        None
132    }
133
134    fn session_modes(
135        &self,
136        _session_id: &acp::SessionId,
137        _cx: &App,
138    ) -> Option<Rc<dyn AgentSessionModes>> {
139        None
140    }
141
142    fn session_config_options(
143        &self,
144        _session_id: &acp::SessionId,
145        _cx: &App,
146    ) -> Option<Rc<dyn AgentSessionConfigOptions>> {
147        None
148    }
149
150    fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
151        None
152    }
153
154    fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
155}
156
157impl dyn AgentConnection {
158    pub fn downcast<T: 'static + AgentConnection + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
159        self.into_any().downcast().ok()
160    }
161}
162
163pub trait AgentSessionTruncate {
164    fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task<Result<()>>;
165}
166
167pub trait AgentSessionRetry {
168    fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>>;
169}
170
171pub trait AgentSessionSetTitle {
172    fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>>;
173}
174
175pub trait AgentTelemetry {
176    /// A representation of the current thread state that can be serialized for
177    /// storage with telemetry events.
178    fn thread_data(
179        &self,
180        session_id: &acp::SessionId,
181        cx: &mut App,
182    ) -> Task<Result<serde_json::Value>>;
183}
184
185pub trait AgentSessionModes {
186    fn current_mode(&self) -> acp::SessionModeId;
187
188    fn all_modes(&self) -> Vec<acp::SessionMode>;
189
190    fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
191}
192
193pub trait AgentSessionConfigOptions {
194    /// Get all current config options with their state
195    fn config_options(&self) -> Vec<acp::SessionConfigOption>;
196
197    /// Set a config option value
198    /// Returns the full updated list of config options
199    fn set_config_option(
200        &self,
201        config_id: acp::SessionConfigId,
202        value: acp::SessionConfigValueId,
203        cx: &mut App,
204    ) -> Task<Result<Vec<acp::SessionConfigOption>>>;
205
206    /// Whenever the config options are updated the receiver will be notified.
207    /// Optional for agents that don't update their config options dynamically.
208    fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
209        None
210    }
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct AgentSessionListRequest {
215    pub cwd: Option<PathBuf>,
216    pub cursor: Option<String>,
217    pub meta: Option<acp::Meta>,
218}
219
220#[derive(Debug, Clone)]
221pub struct AgentSessionListResponse {
222    pub sessions: Vec<AgentSessionInfo>,
223    pub next_cursor: Option<String>,
224    pub meta: Option<acp::Meta>,
225}
226
227impl AgentSessionListResponse {
228    pub fn new(sessions: Vec<AgentSessionInfo>) -> Self {
229        Self {
230            sessions,
231            next_cursor: None,
232            meta: None,
233        }
234    }
235}
236
237#[derive(Debug, Clone, PartialEq)]
238pub struct AgentSessionInfo {
239    pub session_id: acp::SessionId,
240    pub cwd: Option<PathBuf>,
241    pub title: Option<SharedString>,
242    pub updated_at: Option<DateTime<Utc>>,
243    pub meta: Option<acp::Meta>,
244}
245
246impl AgentSessionInfo {
247    pub fn new(session_id: impl Into<acp::SessionId>) -> Self {
248        Self {
249            session_id: session_id.into(),
250            cwd: None,
251            title: None,
252            updated_at: None,
253            meta: None,
254        }
255    }
256}
257
258#[derive(Debug, Clone)]
259pub enum SessionListUpdate {
260    Refresh,
261    SessionInfo {
262        session_id: acp::SessionId,
263        update: acp::SessionInfoUpdate,
264    },
265}
266
267pub trait AgentSessionList {
268    fn list_sessions(
269        &self,
270        request: AgentSessionListRequest,
271        cx: &mut App,
272    ) -> Task<Result<AgentSessionListResponse>>;
273
274    fn supports_delete(&self) -> bool {
275        false
276    }
277
278    fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
279        Task::ready(Err(anyhow::anyhow!("delete_session not supported")))
280    }
281
282    fn delete_sessions(&self, _cx: &mut App) -> Task<Result<()>> {
283        Task::ready(Err(anyhow::anyhow!("delete_sessions not supported")))
284    }
285
286    fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
287        None
288    }
289
290    fn notify_refresh(&self) {}
291
292    fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
293}
294
295impl dyn AgentSessionList {
296    pub fn downcast<T: 'static + AgentSessionList + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
297        self.into_any().downcast().ok()
298    }
299}
300
301#[derive(Debug)]
302pub struct AuthRequired {
303    pub description: Option<String>,
304    pub provider_id: Option<LanguageModelProviderId>,
305}
306
307impl AuthRequired {
308    pub fn new() -> Self {
309        Self {
310            description: None,
311            provider_id: None,
312        }
313    }
314
315    pub fn with_description(mut self, description: String) -> Self {
316        self.description = Some(description);
317        self
318    }
319
320    pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self {
321        self.provider_id = Some(provider_id);
322        self
323    }
324}
325
326impl Error for AuthRequired {}
327impl fmt::Display for AuthRequired {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        write!(f, "Authentication required")
330    }
331}
332
333/// Trait for agents that support listing, selecting, and querying language models.
334///
335/// This is an optional capability; agents indicate support via [AgentConnection::model_selector].
336pub trait AgentModelSelector: 'static {
337    /// Lists all available language models for this agent.
338    ///
339    /// # Parameters
340    /// - `cx`: The GPUI app context for async operations and global access.
341    ///
342    /// # Returns
343    /// A task resolving to the list of models or an error (e.g., if no models are configured).
344    fn list_models(&self, cx: &mut App) -> Task<Result<AgentModelList>>;
345
346    /// Selects a model for a specific session (thread).
347    ///
348    /// This sets the default model for future interactions in the session.
349    /// If the session doesn't exist or the model is invalid, it returns an error.
350    ///
351    /// # Parameters
352    /// - `model`: The model to select (should be one from [list_models]).
353    /// - `cx`: The GPUI app context.
354    ///
355    /// # Returns
356    /// A task resolving to `Ok(())` on success or an error.
357    fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task<Result<()>>;
358
359    /// Retrieves the currently selected model for a specific session (thread).
360    ///
361    /// # Parameters
362    /// - `cx`: The GPUI app context.
363    ///
364    /// # Returns
365    /// A task resolving to the selected model (always set) or an error (e.g., session not found).
366    fn selected_model(&self, cx: &mut App) -> Task<Result<AgentModelInfo>>;
367
368    /// Whenever the model list is updated the receiver will be notified.
369    /// Optional for agents that don't update their model list.
370    fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
371        None
372    }
373
374    /// Returns whether the model picker should render a footer.
375    fn should_render_footer(&self) -> bool {
376        false
377    }
378}
379
380/// Icon for a model in the model selector.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum AgentModelIcon {
383    /// A built-in icon from Zed's icon set.
384    Named(IconName),
385    /// Path to a custom SVG icon file.
386    Path(SharedString),
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct AgentModelInfo {
391    pub id: acp::ModelId,
392    pub name: SharedString,
393    pub description: Option<SharedString>,
394    pub icon: Option<AgentModelIcon>,
395    pub is_latest: bool,
396}
397
398impl From<acp::ModelInfo> for AgentModelInfo {
399    fn from(info: acp::ModelInfo) -> Self {
400        Self {
401            id: info.model_id,
402            name: info.name.into(),
403            description: info.description.map(|desc| desc.into()),
404            icon: None,
405            is_latest: false,
406        }
407    }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Hash)]
411pub struct AgentModelGroupName(pub SharedString);
412
413#[derive(Debug, Clone)]
414pub enum AgentModelList {
415    Flat(Vec<AgentModelInfo>),
416    Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
417}
418
419impl AgentModelList {
420    pub fn is_empty(&self) -> bool {
421        match self {
422            AgentModelList::Flat(models) => models.is_empty(),
423            AgentModelList::Grouped(groups) => groups.is_empty(),
424        }
425    }
426
427    pub fn is_flat(&self) -> bool {
428        matches!(self, AgentModelList::Flat(_))
429    }
430}
431
432#[derive(Debug, Clone)]
433pub struct PermissionOptionChoice {
434    pub allow: acp::PermissionOption,
435    pub deny: acp::PermissionOption,
436}
437
438impl PermissionOptionChoice {
439    pub fn label(&self) -> SharedString {
440        self.allow.name.clone().into()
441    }
442}
443
444#[derive(Debug, Clone)]
445pub enum PermissionOptions {
446    Flat(Vec<acp::PermissionOption>),
447    Dropdown(Vec<PermissionOptionChoice>),
448}
449
450impl PermissionOptions {
451    pub fn is_empty(&self) -> bool {
452        match self {
453            PermissionOptions::Flat(options) => options.is_empty(),
454            PermissionOptions::Dropdown(options) => options.is_empty(),
455        }
456    }
457
458    pub fn first_option_of_kind(
459        &self,
460        kind: acp::PermissionOptionKind,
461    ) -> Option<&acp::PermissionOption> {
462        match self {
463            PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind),
464            PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| {
465                if choice.allow.kind == kind {
466                    Some(&choice.allow)
467                } else if choice.deny.kind == kind {
468                    Some(&choice.deny)
469                } else {
470                    None
471                }
472            }),
473        }
474    }
475
476    pub fn allow_once_option_id(&self) -> Option<acp::PermissionOptionId> {
477        self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
478            .map(|option| option.option_id.clone())
479    }
480
481    pub fn deny_once_option_id(&self) -> Option<acp::PermissionOptionId> {
482        self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce)
483            .map(|option| option.option_id.clone())
484    }
485}
486
487#[cfg(feature = "test-support")]
488mod test_support {
489    //! Test-only stubs and helpers for acp_thread.
490    //!
491    //! This module is gated by the `test-support` feature and is not included
492    //! in production builds. It provides:
493    //! - `StubAgentConnection` for mocking agent connections in tests
494    //! - `create_test_png_base64` for generating test images
495
496    use std::sync::Arc;
497
498    use action_log::ActionLog;
499    use collections::HashMap;
500    use futures::{channel::oneshot, future::try_join_all};
501    use gpui::{AppContext as _, WeakEntity};
502    use parking_lot::Mutex;
503
504    use super::*;
505
506    /// Creates a PNG image encoded as base64 for testing.
507    ///
508    /// Generates a solid-color PNG of the specified dimensions and returns
509    /// it as a base64-encoded string suitable for use in `ImageContent`.
510    pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
511        use image::ImageEncoder as _;
512
513        let mut png_data = Vec::new();
514        {
515            let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
516            let mut pixels = Vec::with_capacity((width * height * 4) as usize);
517            for _ in 0..(width * height) {
518                pixels.extend_from_slice(&color);
519            }
520            encoder
521                .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
522                .expect("Failed to encode PNG");
523        }
524
525        use image::EncodableLayout as _;
526        base64::Engine::encode(
527            &base64::engine::general_purpose::STANDARD,
528            png_data.as_bytes(),
529        )
530    }
531
532    #[derive(Clone, Default)]
533    pub struct StubAgentConnection {
534        sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
535        permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
536        next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
537    }
538
539    struct Session {
540        thread: WeakEntity<AcpThread>,
541        response_tx: Option<oneshot::Sender<acp::StopReason>>,
542    }
543
544    impl StubAgentConnection {
545        pub fn new() -> Self {
546            Self {
547                next_prompt_updates: Default::default(),
548                permission_requests: HashMap::default(),
549                sessions: Arc::default(),
550            }
551        }
552
553        pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
554            *self.next_prompt_updates.lock() = updates;
555        }
556
557        pub fn with_permission_requests(
558            mut self,
559            permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
560        ) -> Self {
561            self.permission_requests = permission_requests;
562            self
563        }
564
565        pub fn send_update(
566            &self,
567            session_id: acp::SessionId,
568            update: acp::SessionUpdate,
569            cx: &mut App,
570        ) {
571            assert!(
572                self.next_prompt_updates.lock().is_empty(),
573                "Use either send_update or set_next_prompt_updates"
574            );
575
576            self.sessions
577                .lock()
578                .get(&session_id)
579                .unwrap()
580                .thread
581                .update(cx, |thread, cx| {
582                    thread.handle_session_update(update, cx).unwrap();
583                })
584                .unwrap();
585        }
586
587        pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
588            self.sessions
589                .lock()
590                .get_mut(&session_id)
591                .unwrap()
592                .response_tx
593                .take()
594                .expect("No pending turn")
595                .send(stop_reason)
596                .unwrap();
597        }
598    }
599
600    impl AgentConnection for StubAgentConnection {
601        fn telemetry_id(&self) -> SharedString {
602            "stub".into()
603        }
604
605        fn auth_methods(&self) -> &[acp::AuthMethod] {
606            &[]
607        }
608
609        fn model_selector(
610            &self,
611            _session_id: &acp::SessionId,
612        ) -> Option<Rc<dyn AgentModelSelector>> {
613            Some(self.model_selector_impl())
614        }
615
616        fn new_session(
617            self: Rc<Self>,
618            project: Entity<Project>,
619            _cwd: &Path,
620            cx: &mut gpui::App,
621        ) -> Task<gpui::Result<Entity<AcpThread>>> {
622            let session_id = acp::SessionId::new(self.sessions.lock().len().to_string());
623            let action_log = cx.new(|_| ActionLog::new(project.clone()));
624            let thread = cx.new(|cx| {
625                AcpThread::new(
626                    None,
627                    "Test",
628                    self.clone(),
629                    project,
630                    action_log,
631                    session_id.clone(),
632                    watch::Receiver::constant(
633                        acp::PromptCapabilities::new()
634                            .image(true)
635                            .audio(true)
636                            .embedded_context(true),
637                    ),
638                    cx,
639                )
640            });
641            self.sessions.lock().insert(
642                session_id,
643                Session {
644                    thread: thread.downgrade(),
645                    response_tx: None,
646                },
647            );
648            Task::ready(Ok(thread))
649        }
650
651        fn authenticate(
652            &self,
653            _method_id: acp::AuthMethodId,
654            _cx: &mut App,
655        ) -> Task<gpui::Result<()>> {
656            unimplemented!()
657        }
658
659        fn prompt(
660            &self,
661            _id: Option<UserMessageId>,
662            params: acp::PromptRequest,
663            cx: &mut App,
664        ) -> Task<gpui::Result<acp::PromptResponse>> {
665            let mut sessions = self.sessions.lock();
666            let Session {
667                thread,
668                response_tx,
669            } = sessions.get_mut(&params.session_id).unwrap();
670            let mut tasks = vec![];
671            if self.next_prompt_updates.lock().is_empty() {
672                let (tx, rx) = oneshot::channel();
673                response_tx.replace(tx);
674                cx.spawn(async move |_| {
675                    let stop_reason = rx.await?;
676                    Ok(acp::PromptResponse::new(stop_reason))
677                })
678            } else {
679                for update in self.next_prompt_updates.lock().drain(..) {
680                    let thread = thread.clone();
681                    let update = update.clone();
682                    let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
683                        &update
684                        && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
685                    {
686                        Some((tool_call.clone(), options.clone()))
687                    } else {
688                        None
689                    };
690                    let task = cx.spawn(async move |cx| {
691                        if let Some((tool_call, options)) = permission_request {
692                            thread
693                                .update(cx, |thread, cx| {
694                                    thread.request_tool_call_authorization(
695                                        tool_call.clone().into(),
696                                        options.clone(),
697                                        cx,
698                                    )
699                                })??
700                                .await;
701                        }
702                        thread.update(cx, |thread, cx| {
703                            thread.handle_session_update(update.clone(), cx).unwrap();
704                        })?;
705                        anyhow::Ok(())
706                    });
707                    tasks.push(task);
708                }
709
710                cx.spawn(async move |_| {
711                    try_join_all(tasks).await?;
712                    Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
713                })
714            }
715        }
716
717        fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
718            if let Some(end_turn_tx) = self
719                .sessions
720                .lock()
721                .get_mut(session_id)
722                .unwrap()
723                .response_tx
724                .take()
725            {
726                end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
727            }
728        }
729
730        fn truncate(
731            &self,
732            _session_id: &agent_client_protocol::SessionId,
733            _cx: &App,
734        ) -> Option<Rc<dyn AgentSessionTruncate>> {
735            Some(Rc::new(StubAgentSessionEditor))
736        }
737
738        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
739            self
740        }
741    }
742
743    struct StubAgentSessionEditor;
744
745    impl AgentSessionTruncate for StubAgentSessionEditor {
746        fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
747            Task::ready(Ok(()))
748        }
749    }
750
751    #[derive(Clone)]
752    struct StubModelSelector {
753        selected_model: Arc<Mutex<AgentModelInfo>>,
754    }
755
756    impl StubModelSelector {
757        fn new() -> Self {
758            Self {
759                selected_model: Arc::new(Mutex::new(AgentModelInfo {
760                    id: acp::ModelId::new("visual-test-model"),
761                    name: "Visual Test Model".into(),
762                    description: Some("A stub model for visual testing".into()),
763                    icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
764                    is_latest: false,
765                })),
766            }
767        }
768    }
769
770    impl AgentModelSelector for StubModelSelector {
771        fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
772            let model = self.selected_model.lock().clone();
773            Task::ready(Ok(AgentModelList::Flat(vec![model])))
774        }
775
776        fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
777            self.selected_model.lock().id = model_id;
778            Task::ready(Ok(()))
779        }
780
781        fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
782            Task::ready(Ok(self.selected_model.lock().clone()))
783        }
784    }
785
786    impl StubAgentConnection {
787        /// Returns a model selector for this stub connection.
788        pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
789            Rc::new(StubModelSelector::new())
790        }
791    }
792}
793
794#[cfg(feature = "test-support")]
795pub use test_support::*;