use crate::{AcpThread, ElicitationStore}; use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; use language_model::DisabledReason; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; use task::{HideStrategy, SpawnInTerminal, TaskId}; use ui::{App, IconName}; use util::path_list::PathList; use uuid::Uuid; /// A user-message ID generated by Zed and passed to agents that support client IDs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct ClientUserMessageId(SharedString); impl ClientUserMessageId { pub fn new() -> Self { Self(Uuid::new_v4().to_string().into()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct AgentModelId(SharedString); impl AgentModelId { pub fn new(id: impl Into) -> Self { id.into() } pub fn as_str(&self) -> &str { self.0.as_ref() } } impl AsRef for AgentModelId { fn as_ref(&self) -> &str { self.as_str() } } impl fmt::Display for AgentModelId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl From for AgentModelId { fn from(id: SharedString) -> Self { Self(id) } } impl From for AgentModelId { fn from(id: String) -> Self { Self(SharedString::from(id)) } } impl From<&str> for AgentModelId { fn from(id: &str) -> Self { Self(SharedString::from(id.to_owned())) } } pub fn build_terminal_auth_task( id: String, label: String, command: String, args: Vec, env: HashMap, ) -> SpawnInTerminal { SpawnInTerminal { id: TaskId(id), full_label: label.clone(), label: label.clone(), command: Some(command), args, command_label: label, env, use_new_terminal: true, allow_concurrent_runs: true, hide: HideStrategy::Always, ..Default::default() } } pub trait AgentConnection { fn agent_id(&self) -> AgentId; fn telemetry_id(&self) -> SharedString; fn agent_version(&self) -> Option { None } fn new_session( self: Rc, project: Entity, _work_dirs: PathList, cx: &mut App, ) -> Task>>; /// Whether this agent supports loading existing sessions. fn supports_load_session(&self) -> bool { false } /// Load an existing session by ID. fn load_session( self: Rc, _session_id: acp::SessionId, _project: Entity, _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported"))) } /// Whether this agent supports closing existing sessions. fn supports_close_session(&self) -> bool { false } /// Close an existing session. Allows the agent to free the session from memory. fn close_session( self: Rc, _session_id: &acp::SessionId, _cx: &mut App, ) -> Task> { Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported"))) } /// Whether this agent supports resuming existing sessions without loading history. fn supports_resume_session(&self) -> bool { false } /// Resume an existing session by ID without replaying previous messages. fn resume_session( self: Rc, _session_id: acp::SessionId, _project: Entity, _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { Task::ready(Err(anyhow::Error::msg( "Resuming sessions is not supported", ))) } /// Whether this agent supports showing session history. fn supports_session_history(&self) -> bool { self.supports_load_session() || self.supports_resume_session() } /// Whether this agent supports additional session directories. fn supports_session_additional_directories(&self) -> bool { false } fn auth_methods(&self) -> &[acp::AuthMethod]; fn terminal_auth_task( &self, _method: &acp::AuthMethodId, _cx: &App, ) -> Option>> { None } fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task>; fn supports_logout(&self) -> bool { false } fn logout(&self, _cx: &mut App) -> Task> { Task::ready(Err(anyhow::Error::msg("Logout is not supported"))) } /// Returns a capability for agents that accept client-generated user message IDs. fn client_user_message_ids( &self, _cx: &App, ) -> Option> { None } fn prompt(&self, params: acp::PromptRequest, cx: &mut App) -> Task>; fn retry(&self, _session_id: &acp::SessionId, _cx: &App) -> Option> { None } fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); /// Request-scoped elicitations are connection-level because they can arrive before a session /// thread exists. Session-scoped elicitations stay in the thread timeline, but use /// `ElicitationStore` for shared processing. fn request_elicitations(&self) -> Option> { None } fn truncate( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { None } fn set_title( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { None } /// Returns this agent as an [Rc] if the model selection capability is supported. /// /// If the agent does not support model selection, returns [None]. /// This allows sharing the selector in UI components. fn model_selector(&self, _session_id: &acp::SessionId) -> Option> { None } fn telemetry(&self) -> Option> { None } fn session_modes( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { None } fn session_config_options( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { None } fn session_list(&self, _cx: &mut App) -> Option> { None } fn into_any(self: Rc) -> Rc; } impl dyn AgentConnection { pub fn downcast(self: Rc) -> Option> { self.into_any().downcast().ok() } } pub trait AgentSessionTruncate { fn run(&self, client_user_message_id: ClientUserMessageId, cx: &mut App) -> Task>; } pub trait AgentSessionClientUserMessageIds { fn new_id(&self) -> ClientUserMessageId { ClientUserMessageId::new() } fn prompt( &self, client_user_message_id: ClientUserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task>; } pub trait AgentSessionRetry { fn run(&self, cx: &mut App) -> Task>; } pub trait AgentSessionSetTitle { fn run(&self, title: SharedString, cx: &mut App) -> Task>; } pub trait AgentTelemetry { /// A representation of the current thread state that can be serialized for /// storage with telemetry events. fn thread_data( &self, session_id: &acp::SessionId, cx: &mut App, ) -> Task>; } pub trait AgentSessionModes { fn current_mode(&self) -> acp::SessionModeId; fn all_modes(&self) -> Vec; fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task>; } pub trait AgentSessionConfigOptions { /// Get all current config options with their state fn config_options(&self) -> Vec; /// Set a config option value /// Returns the full updated list of config options fn set_config_option( &self, config_id: acp::SessionConfigId, value: acp::SessionConfigOptionValue, cx: &mut App, ) -> Task>>; /// Whenever the config options are updated the receiver will be notified. /// Optional for agents that don't update their config options dynamically. fn watch(&self, _cx: &mut App) -> Option> { None } } #[derive(Debug, Clone, Default)] pub struct AgentSessionListRequest { pub cwd: Option, pub cursor: Option, pub meta: Option, } #[derive(Debug, Clone)] pub struct AgentSessionListResponse { pub sessions: Vec, pub next_cursor: Option, pub meta: Option, } impl AgentSessionListResponse { pub fn new(sessions: Vec) -> Self { Self { sessions, next_cursor: None, meta: None, } } } #[derive(Debug, Clone, PartialEq)] pub struct AgentSessionInfo { pub session_id: acp::SessionId, pub work_dirs: Option, pub title: Option, pub updated_at: Option>, pub created_at: Option>, pub meta: Option, } impl AgentSessionInfo { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), work_dirs: None, title: None, updated_at: None, created_at: None, meta: None, } } } #[derive(Debug, Clone)] pub enum SessionListUpdate { Refresh, SessionInfo { session_id: acp::SessionId, update: acp::SessionInfoUpdate, }, } pub trait AgentSessionList { fn list_sessions( &self, request: AgentSessionListRequest, cx: &mut App, ) -> Task>; fn supports_delete(&self) -> bool { false } fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { Task::ready(Err(anyhow::anyhow!("delete_session not supported"))) } fn delete_sessions(&self, _cx: &mut App) -> Task> { Task::ready(Err(anyhow::anyhow!("delete_sessions not supported"))) } fn watch(&self, _cx: &mut App) -> Option> { None } fn notify_refresh(&self) {} fn into_any(self: Rc) -> Rc; } impl dyn AgentSessionList { pub fn downcast(self: Rc) -> Option> { self.into_any().downcast().ok() } } #[derive(Debug)] pub struct AuthRequired { pub description: Option, } impl AuthRequired { pub fn new() -> Self { Self { description: None } } pub fn with_description(mut self, description: String) -> Self { self.description = Some(description); self } } impl Error for AuthRequired {} impl fmt::Display for AuthRequired { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Authentication required") } } /// Trait for agents that support listing, selecting, and querying language models. /// /// This is an optional capability; agents indicate support via [AgentConnection::model_selector]. pub trait AgentModelSelector: 'static { /// Lists all available language models for this agent. /// /// # Parameters /// - `cx`: The GPUI app context for async operations and global access. /// /// # Returns /// A task resolving to the list of models or an error (e.g., if no models are configured). fn list_models(&self, cx: &mut App) -> Task>; /// Selects a model for a specific session (thread). /// /// # Parameters /// - `model_id`: The model to select (should be one from [list_models]). /// - `cx`: The GPUI app context. /// /// # Returns /// A task resolving to `Ok(())` on success or an error. fn select_model(&self, model_id: AgentModelId, cx: &mut App) -> Task>; /// Retrieves the currently selected model for a specific session (thread). /// /// # Parameters /// - `cx`: The GPUI app context. /// /// # Returns /// A task resolving to the selected model (always set) or an error (e.g., session not found). fn selected_model(&self, cx: &mut App) -> Task>; fn favorite_model_ids(&self, _cx: &mut App) -> HashSet { HashSet::default() } fn toggle_favorite_model(&self, _model_id: AgentModelId, _should_be_favorite: bool, _cx: &App) { } /// Whenever the model list is updated the receiver will be notified. /// Optional for agents that don't update their model list. fn watch(&self, _cx: &mut App) -> Option> { None } /// Returns whether the model picker should render a footer. fn should_render_footer(&self) -> bool { false } } /// Icon for a model in the model selector. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AgentModelIcon { /// A built-in icon from Zed's icon set. Named(IconName), /// Path to a custom SVG icon file. Path(SharedString), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentModelInfo { pub id: AgentModelId, pub name: SharedString, pub description: Option, pub icon: Option, pub is_latest: bool, pub cost: Option, pub disabled: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AgentModelGroupName(pub SharedString); #[derive(Debug, Clone)] pub enum AgentModelList { Flat(Vec), Grouped(IndexMap>), } impl AgentModelList { pub fn is_empty(&self) -> bool { match self { AgentModelList::Flat(models) => models.is_empty(), AgentModelList::Grouped(groups) => groups.is_empty(), } } pub fn is_flat(&self) -> bool { matches!(self, AgentModelList::Flat(_)) } } #[derive(Debug, Clone)] pub struct PermissionOptionChoice { pub allow: acp::PermissionOption, pub deny: acp::PermissionOption, pub sub_patterns: Vec, } impl PermissionOptionChoice { pub fn label(&self) -> SharedString { self.allow.name.clone().into() } /// Build a `SelectedPermissionOutcome` for this choice. /// /// If the choice carries `sub_patterns`, they are attached as /// `SelectedPermissionParams::Terminal`. pub fn build_outcome(&self, is_allow: bool) -> crate::SelectedPermissionOutcome { let option = if is_allow { &self.allow } else { &self.deny }; let params = if !self.sub_patterns.is_empty() { Some(crate::SelectedPermissionParams::Terminal { patterns: self.sub_patterns.clone(), }) } else { None }; crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind).params(params) } } /// Pairs a tool's permission pattern with its display name /// /// For example, a pattern of `^cargo\\s+build(\\s|$)` would display as `cargo /// build`. It's handy to keep these together rather than trying to derive /// one from the other. #[derive(Debug, Clone, PartialEq)] pub struct PermissionPattern { pub pattern: String, pub display_name: String, } #[derive(Debug, Clone)] pub enum PermissionOptions { Flat(Vec), Dropdown(Vec), DropdownWithPatterns { choices: Vec, patterns: Vec, tool_name: String, }, } impl PermissionOptions { pub fn is_empty(&self) -> bool { match self { PermissionOptions::Flat(options) => options.is_empty(), PermissionOptions::Dropdown(options) => options.is_empty(), PermissionOptions::DropdownWithPatterns { choices, .. } => choices.is_empty(), } } pub fn first_option_of_kind( &self, kind: acp::PermissionOptionKind, ) -> Option<&acp::PermissionOption> { match self { PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind), PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| { if choice.allow.kind == kind { Some(&choice.allow) } else if choice.deny.kind == kind { Some(&choice.deny) } else { None } }), PermissionOptions::DropdownWithPatterns { choices, .. } => { choices.iter().find_map(|choice| { if choice.allow.kind == kind { Some(&choice.allow) } else if choice.deny.kind == kind { Some(&choice.deny) } else { None } }) } } } pub fn allow_once_option_id(&self) -> Option { self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce) .map(|option| option.option_id.clone()) } pub fn deny_once_option_id(&self) -> Option { self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce) .map(|option| option.option_id.clone()) } /// Build a `SelectedPermissionOutcome` for the `DropdownWithPatterns` /// variant when the user has checked specific pattern indices. /// /// Returns `Some` with the always-allow/deny outcome when at least one /// pattern is checked. Returns `None` when zero patterns are checked, /// signaling that the caller should degrade to allow-once / deny-once. /// /// Panics (debug) or returns `None` (release) if called on a non- /// `DropdownWithPatterns` variant. pub fn build_outcome_for_checked_patterns( &self, checked_indices: &[usize], is_allow: bool, ) -> Option { let PermissionOptions::DropdownWithPatterns { choices, patterns, .. } = self else { debug_assert!( false, "build_outcome_for_checked_patterns called on non-DropdownWithPatterns" ); return None; }; let checked_patterns: Vec = patterns .iter() .enumerate() .filter(|(index, _)| checked_indices.contains(index)) .map(|(_, cp)| cp.pattern.clone()) .collect(); if checked_patterns.is_empty() { return None; } // Use the first choice (the "Always" choice) as the base for the outcome. let always_choice = choices.first()?; let option = if is_allow { &always_choice.allow } else { &always_choice.deny }; let outcome = crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind) .params(Some(crate::SelectedPermissionParams::Terminal { patterns: checked_patterns, })); Some(outcome) } } #[cfg(feature = "test-support")] mod test_support { //! Test-only stubs and helpers for acp_thread. //! //! This module is gated by the `test-support` feature and is not included //! in production builds. It provides: //! - `StubAgentConnection` for mocking agent connections in tests //! - `create_test_png_base64` for generating test images use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use action_log::ActionLog; use collections::HashMap; use futures::{channel::oneshot, future::try_join_all}; use gpui::{AppContext as _, WeakEntity}; use parking_lot::Mutex; use crate::AuthorizationKind; use super::*; /// Creates a PNG image encoded as base64 for testing. /// /// Generates a solid-color PNG of the specified dimensions and returns /// it as a base64-encoded string suitable for use in `ImageContent`. pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String { use image::ImageEncoder as _; let mut png_data = Vec::new(); { let encoder = image::codecs::png::PngEncoder::new(&mut png_data); let mut pixels = Vec::with_capacity((width * height * 4) as usize); for _ in 0..(width * height) { pixels.extend_from_slice(&color); } encoder .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8) .expect("Failed to encode PNG"); } use image::EncodableLayout as _; base64::Engine::encode( &base64::engine::general_purpose::STANDARD, png_data.as_bytes(), ) } /// Test-scoped counter for generating unique session IDs across all /// `StubAgentConnection` instances within a test case. Set as a GPUI /// global in test init so each case starts fresh. pub struct StubSessionCounter(pub AtomicUsize); impl gpui::Global for StubSessionCounter {} impl StubSessionCounter { pub fn next(cx: &App) -> usize { cx.try_global::() .map(|g| g.0.fetch_add(1, Ordering::SeqCst)) .unwrap_or_else(|| { static FALLBACK: AtomicUsize = AtomicUsize::new(0); FALLBACK.fetch_add(1, Ordering::SeqCst) }) } } #[derive(Clone)] pub struct StubAgentConnection { sessions: Arc>>, permission_requests: HashMap, next_prompt_updates: Arc>>, supports_load_session: bool, supports_session_additional_directories: bool, agent_id: AgentId, telemetry_id: SharedString, } struct Session { thread: WeakEntity, response_tx: Option>, } impl Default for StubAgentConnection { fn default() -> Self { Self::new() } } impl StubAgentConnection { pub fn new() -> Self { Self { next_prompt_updates: Default::default(), permission_requests: HashMap::default(), sessions: Arc::default(), supports_load_session: false, supports_session_additional_directories: false, agent_id: AgentId::new("stub"), telemetry_id: "stub".into(), } } pub fn set_next_prompt_updates(&self, updates: Vec) { *self.next_prompt_updates.lock() = updates; } pub fn with_permission_requests( mut self, permission_requests: HashMap, ) -> Self { self.permission_requests = permission_requests; self } pub fn with_supports_load_session(mut self, supports_load_session: bool) -> Self { self.supports_load_session = supports_load_session; self } pub fn with_supports_session_additional_directories( mut self, supports_session_additional_directories: bool, ) -> Self { self.supports_session_additional_directories = supports_session_additional_directories; self } pub fn with_agent_id(mut self, agent_id: AgentId) -> Self { self.agent_id = agent_id; self } pub fn with_telemetry_id(mut self, telemetry_id: SharedString) -> Self { self.telemetry_id = telemetry_id; self } fn create_session( self: Rc, session_id: acp::SessionId, project: Entity, work_dirs: PathList, title: Option, cx: &mut gpui::App, ) -> Entity { let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, title, Some(work_dirs), self.clone(), project, action_log, session_id.clone(), watch::Receiver::constant( acp::PromptCapabilities::new() .image(true) .audio(true) .embedded_context(true), ), cx, ) }); self.sessions.lock().insert( session_id, Session { thread: thread.downgrade(), response_tx: None, }, ); thread } pub fn send_update( &self, session_id: acp::SessionId, update: acp::SessionUpdate, cx: &mut App, ) { assert!( self.next_prompt_updates.lock().is_empty(), "Use either send_update or set_next_prompt_updates" ); self.sessions .lock() .get(&session_id) .unwrap() .thread .update(cx, |thread, cx| { thread.handle_session_update(update, cx).unwrap(); }) .unwrap(); } pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) { self.sessions .lock() .get_mut(&session_id) .unwrap() .response_tx .take() .expect("No pending turn") .send(stop_reason) .unwrap(); } } impl AgentConnection for StubAgentConnection { fn agent_id(&self) -> AgentId { self.agent_id.clone() } fn telemetry_id(&self) -> SharedString { self.telemetry_id.clone() } fn auth_methods(&self) -> &[acp::AuthMethod] { &[] } fn model_selector( &self, _session_id: &acp::SessionId, ) -> Option> { Some(self.model_selector_impl()) } fn new_session( self: Rc, project: Entity, work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let session_id = acp::SessionId::new(StubSessionCounter::next(cx).to_string()); let thread = self.create_session(session_id, project, work_dirs, None, cx); Task::ready(Ok(thread)) } fn supports_load_session(&self) -> bool { self.supports_load_session } fn supports_session_additional_directories(&self) -> bool { self.supports_session_additional_directories } fn load_session( self: Rc, session_id: acp::SessionId, project: Entity, work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { if !self.supports_load_session { return Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported"))); } let thread = self.create_session(session_id, project, work_dirs, title, cx); Task::ready(Ok(thread)) } fn authenticate( &self, _method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { unimplemented!() } fn prompt( &self, params: acp::PromptRequest, cx: &mut App, ) -> Task> { let mut sessions = self.sessions.lock(); let Session { thread, response_tx, } = sessions.get_mut(¶ms.session_id).unwrap(); let mut tasks = vec![]; if self.next_prompt_updates.lock().is_empty() { let (tx, rx) = oneshot::channel(); response_tx.replace(tx); cx.spawn(async move |_| { let stop_reason = rx.await?; Ok(acp::PromptResponse::new(stop_reason)) }) } else { for update in self.next_prompt_updates.lock().drain(..) { let thread = thread.clone(); let update = update.clone(); let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id) { Some((tool_call.clone(), options.clone())) } else { None }; let task = cx.spawn(async move |cx| { if let Some((tool_call, options)) = permission_request { thread .update(cx, |thread, cx| { thread.request_tool_call_authorization( tool_call.clone().into(), options.clone(), AuthorizationKind::PermissionGrant, cx, ) })?? .await; } thread.update(cx, |thread, cx| { thread.handle_session_update(update.clone(), cx).unwrap(); })?; anyhow::Ok(()) }); tasks.push(task); } cx.spawn(async move |_| { try_join_all(tasks).await?; Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } fn client_user_message_ids( &self, _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionClientUserMessageIds { connection: self.clone(), })) } fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { if let Some(end_turn_tx) = self .sessions .lock() .get_mut(session_id) .unwrap() .response_tx .take() { end_turn_tx.send(acp::StopReason::Cancelled).unwrap(); } } fn set_title( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionSetTitle)) } fn truncate( &self, _session_id: &acp::SessionId, _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) } fn into_any(self: Rc) -> Rc { self } } struct StubAgentSessionSetTitle; impl AgentSessionSetTitle for StubAgentSessionSetTitle { fn run(&self, _title: SharedString, _cx: &mut App) -> Task> { Task::ready(Ok(())) } } struct StubAgentSessionClientUserMessageIds { connection: StubAgentConnection, } impl AgentSessionClientUserMessageIds for StubAgentSessionClientUserMessageIds { fn prompt( &self, _client_user_message_id: ClientUserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { self.connection.prompt(params, cx) } } struct StubAgentSessionEditor; impl AgentSessionTruncate for StubAgentSessionEditor { fn run(&self, _: ClientUserMessageId, _: &mut App) -> Task> { Task::ready(Ok(())) } } #[derive(Clone)] struct StubModelSelector { selected_model: Arc>, } impl StubModelSelector { fn new() -> Self { Self { selected_model: Arc::new(Mutex::new(AgentModelInfo { id: AgentModelId::new("visual-test-model"), name: "Visual Test Model".into(), description: Some("A stub model for visual testing".into()), icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)), is_latest: false, cost: None, disabled: None, })), } } } impl AgentModelSelector for StubModelSelector { fn list_models(&self, _cx: &mut App) -> Task> { let model = self.selected_model.lock().clone(); Task::ready(Ok(AgentModelList::Flat(vec![model]))) } fn select_model(&self, model_id: AgentModelId, _cx: &mut App) -> Task> { self.selected_model.lock().id = model_id; Task::ready(Ok(())) } fn selected_model(&self, _cx: &mut App) -> Task> { Task::ready(Ok(self.selected_model.lock().clone())) } } impl StubAgentConnection { /// Returns a model selector for this stub connection. pub fn model_selector_impl(&self) -> Rc { Rc::new(StubModelSelector::new()) } } } #[cfg(feature = "test-support")] pub use test_support::*;