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(¶ms.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::*;