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