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: 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) -> 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) -> 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) -> bool {
 86        self.supports_load_session() || self.supports_resume_session()
 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    pub cost: Option<SharedString>,
397}
398
399impl From<acp::ModelInfo> for AgentModelInfo {
400    fn from(info: acp::ModelInfo) -> Self {
401        Self {
402            id: info.model_id,
403            name: info.name.into(),
404            description: info.description.map(|desc| desc.into()),
405            icon: None,
406            is_latest: false,
407            cost: None,
408        }
409    }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Hash)]
413pub struct AgentModelGroupName(pub SharedString);
414
415#[derive(Debug, Clone)]
416pub enum AgentModelList {
417    Flat(Vec<AgentModelInfo>),
418    Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
419}
420
421impl AgentModelList {
422    pub fn is_empty(&self) -> bool {
423        match self {
424            AgentModelList::Flat(models) => models.is_empty(),
425            AgentModelList::Grouped(groups) => groups.is_empty(),
426        }
427    }
428
429    pub fn is_flat(&self) -> bool {
430        matches!(self, AgentModelList::Flat(_))
431    }
432}
433
434#[derive(Debug, Clone)]
435pub struct PermissionOptionChoice {
436    pub allow: acp::PermissionOption,
437    pub deny: acp::PermissionOption,
438}
439
440impl PermissionOptionChoice {
441    pub fn label(&self) -> SharedString {
442        self.allow.name.clone().into()
443    }
444}
445
446#[derive(Debug, Clone)]
447pub enum PermissionOptions {
448    Flat(Vec<acp::PermissionOption>),
449    Dropdown(Vec<PermissionOptionChoice>),
450}
451
452impl PermissionOptions {
453    pub fn is_empty(&self) -> bool {
454        match self {
455            PermissionOptions::Flat(options) => options.is_empty(),
456            PermissionOptions::Dropdown(options) => options.is_empty(),
457        }
458    }
459
460    pub fn first_option_of_kind(
461        &self,
462        kind: acp::PermissionOptionKind,
463    ) -> Option<&acp::PermissionOption> {
464        match self {
465            PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind),
466            PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| {
467                if choice.allow.kind == kind {
468                    Some(&choice.allow)
469                } else if choice.deny.kind == kind {
470                    Some(&choice.deny)
471                } else {
472                    None
473                }
474            }),
475        }
476    }
477
478    pub fn allow_once_option_id(&self) -> Option<acp::PermissionOptionId> {
479        self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
480            .map(|option| option.option_id.clone())
481    }
482
483    pub fn deny_once_option_id(&self) -> Option<acp::PermissionOptionId> {
484        self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce)
485            .map(|option| option.option_id.clone())
486    }
487}
488
489#[cfg(feature = "test-support")]
490mod test_support {
491    //! Test-only stubs and helpers for acp_thread.
492    //!
493    //! This module is gated by the `test-support` feature and is not included
494    //! in production builds. It provides:
495    //! - `StubAgentConnection` for mocking agent connections in tests
496    //! - `create_test_png_base64` for generating test images
497
498    use std::sync::Arc;
499
500    use action_log::ActionLog;
501    use collections::HashMap;
502    use futures::{channel::oneshot, future::try_join_all};
503    use gpui::{AppContext as _, WeakEntity};
504    use parking_lot::Mutex;
505
506    use super::*;
507
508    /// Creates a PNG image encoded as base64 for testing.
509    ///
510    /// Generates a solid-color PNG of the specified dimensions and returns
511    /// it as a base64-encoded string suitable for use in `ImageContent`.
512    pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
513        use image::ImageEncoder as _;
514
515        let mut png_data = Vec::new();
516        {
517            let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
518            let mut pixels = Vec::with_capacity((width * height * 4) as usize);
519            for _ in 0..(width * height) {
520                pixels.extend_from_slice(&color);
521            }
522            encoder
523                .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
524                .expect("Failed to encode PNG");
525        }
526
527        use image::EncodableLayout as _;
528        base64::Engine::encode(
529            &base64::engine::general_purpose::STANDARD,
530            png_data.as_bytes(),
531        )
532    }
533
534    #[derive(Clone, Default)]
535    pub struct StubAgentConnection {
536        sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
537        permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
538        next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
539    }
540
541    struct Session {
542        thread: WeakEntity<AcpThread>,
543        response_tx: Option<oneshot::Sender<acp::StopReason>>,
544    }
545
546    impl StubAgentConnection {
547        pub fn new() -> Self {
548            Self {
549                next_prompt_updates: Default::default(),
550                permission_requests: HashMap::default(),
551                sessions: Arc::default(),
552            }
553        }
554
555        pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
556            *self.next_prompt_updates.lock() = updates;
557        }
558
559        pub fn with_permission_requests(
560            mut self,
561            permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
562        ) -> Self {
563            self.permission_requests = permission_requests;
564            self
565        }
566
567        pub fn send_update(
568            &self,
569            session_id: acp::SessionId,
570            update: acp::SessionUpdate,
571            cx: &mut App,
572        ) {
573            assert!(
574                self.next_prompt_updates.lock().is_empty(),
575                "Use either send_update or set_next_prompt_updates"
576            );
577
578            self.sessions
579                .lock()
580                .get(&session_id)
581                .unwrap()
582                .thread
583                .update(cx, |thread, cx| {
584                    thread.handle_session_update(update, cx).unwrap();
585                })
586                .unwrap();
587        }
588
589        pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
590            self.sessions
591                .lock()
592                .get_mut(&session_id)
593                .unwrap()
594                .response_tx
595                .take()
596                .expect("No pending turn")
597                .send(stop_reason)
598                .unwrap();
599        }
600    }
601
602    impl AgentConnection for StubAgentConnection {
603        fn telemetry_id(&self) -> SharedString {
604            "stub".into()
605        }
606
607        fn auth_methods(&self) -> &[acp::AuthMethod] {
608            &[]
609        }
610
611        fn model_selector(
612            &self,
613            _session_id: &acp::SessionId,
614        ) -> Option<Rc<dyn AgentModelSelector>> {
615            Some(self.model_selector_impl())
616        }
617
618        fn new_session(
619            self: Rc<Self>,
620            project: Entity<Project>,
621            _cwd: &Path,
622            cx: &mut gpui::App,
623        ) -> Task<gpui::Result<Entity<AcpThread>>> {
624            let session_id = acp::SessionId::new(self.sessions.lock().len().to_string());
625            let action_log = cx.new(|_| ActionLog::new(project.clone()));
626            let thread = cx.new(|cx| {
627                AcpThread::new(
628                    None,
629                    "Test",
630                    self.clone(),
631                    project,
632                    action_log,
633                    session_id.clone(),
634                    watch::Receiver::constant(
635                        acp::PromptCapabilities::new()
636                            .image(true)
637                            .audio(true)
638                            .embedded_context(true),
639                    ),
640                    cx,
641                )
642            });
643            self.sessions.lock().insert(
644                session_id,
645                Session {
646                    thread: thread.downgrade(),
647                    response_tx: None,
648                },
649            );
650            Task::ready(Ok(thread))
651        }
652
653        fn authenticate(
654            &self,
655            _method_id: acp::AuthMethodId,
656            _cx: &mut App,
657        ) -> Task<gpui::Result<()>> {
658            unimplemented!()
659        }
660
661        fn prompt(
662            &self,
663            _id: Option<UserMessageId>,
664            params: acp::PromptRequest,
665            cx: &mut App,
666        ) -> Task<gpui::Result<acp::PromptResponse>> {
667            let mut sessions = self.sessions.lock();
668            let Session {
669                thread,
670                response_tx,
671            } = sessions.get_mut(&params.session_id).unwrap();
672            let mut tasks = vec![];
673            if self.next_prompt_updates.lock().is_empty() {
674                let (tx, rx) = oneshot::channel();
675                response_tx.replace(tx);
676                cx.spawn(async move |_| {
677                    let stop_reason = rx.await?;
678                    Ok(acp::PromptResponse::new(stop_reason))
679                })
680            } else {
681                for update in self.next_prompt_updates.lock().drain(..) {
682                    let thread = thread.clone();
683                    let update = update.clone();
684                    let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
685                        &update
686                        && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
687                    {
688                        Some((tool_call.clone(), options.clone()))
689                    } else {
690                        None
691                    };
692                    let task = cx.spawn(async move |cx| {
693                        if let Some((tool_call, options)) = permission_request {
694                            thread
695                                .update(cx, |thread, cx| {
696                                    thread.request_tool_call_authorization(
697                                        tool_call.clone().into(),
698                                        options.clone(),
699                                        cx,
700                                    )
701                                })??
702                                .await;
703                        }
704                        thread.update(cx, |thread, cx| {
705                            thread.handle_session_update(update.clone(), cx).unwrap();
706                        })?;
707                        anyhow::Ok(())
708                    });
709                    tasks.push(task);
710                }
711
712                cx.spawn(async move |_| {
713                    try_join_all(tasks).await?;
714                    Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
715                })
716            }
717        }
718
719        fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
720            if let Some(end_turn_tx) = self
721                .sessions
722                .lock()
723                .get_mut(session_id)
724                .unwrap()
725                .response_tx
726                .take()
727            {
728                end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
729            }
730        }
731
732        fn set_title(
733            &self,
734            _session_id: &acp::SessionId,
735            _cx: &App,
736        ) -> Option<Rc<dyn AgentSessionSetTitle>> {
737            Some(Rc::new(StubAgentSessionSetTitle))
738        }
739
740        fn truncate(
741            &self,
742            _session_id: &agent_client_protocol::SessionId,
743            _cx: &App,
744        ) -> Option<Rc<dyn AgentSessionTruncate>> {
745            Some(Rc::new(StubAgentSessionEditor))
746        }
747
748        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
749            self
750        }
751    }
752
753    struct StubAgentSessionSetTitle;
754
755    impl AgentSessionSetTitle for StubAgentSessionSetTitle {
756        fn run(&self, _title: SharedString, _cx: &mut App) -> Task<Result<()>> {
757            Task::ready(Ok(()))
758        }
759    }
760
761    struct StubAgentSessionEditor;
762
763    impl AgentSessionTruncate for StubAgentSessionEditor {
764        fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
765            Task::ready(Ok(()))
766        }
767    }
768
769    #[derive(Clone)]
770    struct StubModelSelector {
771        selected_model: Arc<Mutex<AgentModelInfo>>,
772    }
773
774    impl StubModelSelector {
775        fn new() -> Self {
776            Self {
777                selected_model: Arc::new(Mutex::new(AgentModelInfo {
778                    id: acp::ModelId::new("visual-test-model"),
779                    name: "Visual Test Model".into(),
780                    description: Some("A stub model for visual testing".into()),
781                    icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
782                    is_latest: false,
783                    cost: None,
784                })),
785            }
786        }
787    }
788
789    impl AgentModelSelector for StubModelSelector {
790        fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
791            let model = self.selected_model.lock().clone();
792            Task::ready(Ok(AgentModelList::Flat(vec![model])))
793        }
794
795        fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
796            self.selected_model.lock().id = model_id;
797            Task::ready(Ok(()))
798        }
799
800        fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
801            Task::ready(Ok(self.selected_model.lock().clone()))
802        }
803    }
804
805    impl StubAgentConnection {
806        /// Returns a model selector for this stub connection.
807        pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
808            Rc::new(StubModelSelector::new())
809        }
810    }
811}
812
813#[cfg(feature = "test-support")]
814pub use test_support::*;