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