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    use std::sync::atomic::{AtomicUsize, Ordering};
500
501    use action_log::ActionLog;
502    use collections::HashMap;
503    use futures::{channel::oneshot, future::try_join_all};
504    use gpui::{AppContext as _, WeakEntity};
505    use parking_lot::Mutex;
506
507    use super::*;
508
509    /// Creates a PNG image encoded as base64 for testing.
510    ///
511    /// Generates a solid-color PNG of the specified dimensions and returns
512    /// it as a base64-encoded string suitable for use in `ImageContent`.
513    pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
514        use image::ImageEncoder as _;
515
516        let mut png_data = Vec::new();
517        {
518            let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
519            let mut pixels = Vec::with_capacity((width * height * 4) as usize);
520            for _ in 0..(width * height) {
521                pixels.extend_from_slice(&color);
522            }
523            encoder
524                .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
525                .expect("Failed to encode PNG");
526        }
527
528        use image::EncodableLayout as _;
529        base64::Engine::encode(
530            &base64::engine::general_purpose::STANDARD,
531            png_data.as_bytes(),
532        )
533    }
534
535    #[derive(Clone, Default)]
536    pub struct StubAgentConnection {
537        sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
538        permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
539        next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
540    }
541
542    struct Session {
543        thread: WeakEntity<AcpThread>,
544        response_tx: Option<oneshot::Sender<acp::StopReason>>,
545    }
546
547    impl StubAgentConnection {
548        pub fn new() -> Self {
549            Self {
550                next_prompt_updates: Default::default(),
551                permission_requests: HashMap::default(),
552                sessions: Arc::default(),
553            }
554        }
555
556        pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
557            *self.next_prompt_updates.lock() = updates;
558        }
559
560        pub fn with_permission_requests(
561            mut self,
562            permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
563        ) -> Self {
564            self.permission_requests = permission_requests;
565            self
566        }
567
568        pub fn send_update(
569            &self,
570            session_id: acp::SessionId,
571            update: acp::SessionUpdate,
572            cx: &mut App,
573        ) {
574            assert!(
575                self.next_prompt_updates.lock().is_empty(),
576                "Use either send_update or set_next_prompt_updates"
577            );
578
579            self.sessions
580                .lock()
581                .get(&session_id)
582                .unwrap()
583                .thread
584                .update(cx, |thread, cx| {
585                    thread.handle_session_update(update, cx).unwrap();
586                })
587                .unwrap();
588        }
589
590        pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
591            self.sessions
592                .lock()
593                .get_mut(&session_id)
594                .unwrap()
595                .response_tx
596                .take()
597                .expect("No pending turn")
598                .send(stop_reason)
599                .unwrap();
600        }
601    }
602
603    impl AgentConnection for StubAgentConnection {
604        fn telemetry_id(&self) -> SharedString {
605            "stub".into()
606        }
607
608        fn auth_methods(&self) -> &[acp::AuthMethod] {
609            &[]
610        }
611
612        fn model_selector(
613            &self,
614            _session_id: &acp::SessionId,
615        ) -> Option<Rc<dyn AgentModelSelector>> {
616            Some(self.model_selector_impl())
617        }
618
619        fn new_session(
620            self: Rc<Self>,
621            project: Entity<Project>,
622            _cwd: &Path,
623            cx: &mut gpui::App,
624        ) -> Task<gpui::Result<Entity<AcpThread>>> {
625            static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0);
626            let session_id =
627                acp::SessionId::new(NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst).to_string());
628            let action_log = cx.new(|_| ActionLog::new(project.clone()));
629            let thread = cx.new(|cx| {
630                AcpThread::new(
631                    None,
632                    "Test",
633                    self.clone(),
634                    project,
635                    action_log,
636                    session_id.clone(),
637                    watch::Receiver::constant(
638                        acp::PromptCapabilities::new()
639                            .image(true)
640                            .audio(true)
641                            .embedded_context(true),
642                    ),
643                    cx,
644                )
645            });
646            self.sessions.lock().insert(
647                session_id,
648                Session {
649                    thread: thread.downgrade(),
650                    response_tx: None,
651                },
652            );
653            Task::ready(Ok(thread))
654        }
655
656        fn authenticate(
657            &self,
658            _method_id: acp::AuthMethodId,
659            _cx: &mut App,
660        ) -> Task<gpui::Result<()>> {
661            unimplemented!()
662        }
663
664        fn prompt(
665            &self,
666            _id: Option<UserMessageId>,
667            params: acp::PromptRequest,
668            cx: &mut App,
669        ) -> Task<gpui::Result<acp::PromptResponse>> {
670            let mut sessions = self.sessions.lock();
671            let Session {
672                thread,
673                response_tx,
674            } = sessions.get_mut(&params.session_id).unwrap();
675            let mut tasks = vec![];
676            if self.next_prompt_updates.lock().is_empty() {
677                let (tx, rx) = oneshot::channel();
678                response_tx.replace(tx);
679                cx.spawn(async move |_| {
680                    let stop_reason = rx.await?;
681                    Ok(acp::PromptResponse::new(stop_reason))
682                })
683            } else {
684                for update in self.next_prompt_updates.lock().drain(..) {
685                    let thread = thread.clone();
686                    let update = update.clone();
687                    let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
688                        &update
689                        && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
690                    {
691                        Some((tool_call.clone(), options.clone()))
692                    } else {
693                        None
694                    };
695                    let task = cx.spawn(async move |cx| {
696                        if let Some((tool_call, options)) = permission_request {
697                            thread
698                                .update(cx, |thread, cx| {
699                                    thread.request_tool_call_authorization(
700                                        tool_call.clone().into(),
701                                        options.clone(),
702                                        cx,
703                                    )
704                                })??
705                                .await;
706                        }
707                        thread.update(cx, |thread, cx| {
708                            thread.handle_session_update(update.clone(), cx).unwrap();
709                        })?;
710                        anyhow::Ok(())
711                    });
712                    tasks.push(task);
713                }
714
715                cx.spawn(async move |_| {
716                    try_join_all(tasks).await?;
717                    Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
718                })
719            }
720        }
721
722        fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
723            if let Some(end_turn_tx) = self
724                .sessions
725                .lock()
726                .get_mut(session_id)
727                .unwrap()
728                .response_tx
729                .take()
730            {
731                end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
732            }
733        }
734
735        fn set_title(
736            &self,
737            _session_id: &acp::SessionId,
738            _cx: &App,
739        ) -> Option<Rc<dyn AgentSessionSetTitle>> {
740            Some(Rc::new(StubAgentSessionSetTitle))
741        }
742
743        fn truncate(
744            &self,
745            _session_id: &agent_client_protocol::SessionId,
746            _cx: &App,
747        ) -> Option<Rc<dyn AgentSessionTruncate>> {
748            Some(Rc::new(StubAgentSessionEditor))
749        }
750
751        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
752            self
753        }
754    }
755
756    struct StubAgentSessionSetTitle;
757
758    impl AgentSessionSetTitle for StubAgentSessionSetTitle {
759        fn run(&self, _title: SharedString, _cx: &mut App) -> Task<Result<()>> {
760            Task::ready(Ok(()))
761        }
762    }
763
764    struct StubAgentSessionEditor;
765
766    impl AgentSessionTruncate for StubAgentSessionEditor {
767        fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
768            Task::ready(Ok(()))
769        }
770    }
771
772    #[derive(Clone)]
773    struct StubModelSelector {
774        selected_model: Arc<Mutex<AgentModelInfo>>,
775    }
776
777    impl StubModelSelector {
778        fn new() -> Self {
779            Self {
780                selected_model: Arc::new(Mutex::new(AgentModelInfo {
781                    id: acp::ModelId::new("visual-test-model"),
782                    name: "Visual Test Model".into(),
783                    description: Some("A stub model for visual testing".into()),
784                    icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
785                    is_latest: false,
786                    cost: None,
787                })),
788            }
789        }
790    }
791
792    impl AgentModelSelector for StubModelSelector {
793        fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
794            let model = self.selected_model.lock().clone();
795            Task::ready(Ok(AgentModelList::Flat(vec![model])))
796        }
797
798        fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
799            self.selected_model.lock().id = model_id;
800            Task::ready(Ok(()))
801        }
802
803        fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
804            Task::ready(Ok(self.selected_model.lock().clone()))
805        }
806    }
807
808    impl StubAgentConnection {
809        /// Returns a model selector for this stub connection.
810        pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
811            Rc::new(StubModelSelector::new())
812        }
813    }
814}
815
816#[cfg(feature = "test-support")]
817pub use test_support::*;