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_thread(
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, _cx: &App) -> 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 resuming existing sessions without loading history.
57 fn supports_resume_session(&self, _cx: &App) -> bool {
58 false
59 }
60
61 /// Resume an existing session by ID without replaying previous messages.
62 fn resume_session(
63 self: Rc<Self>,
64 _session: AgentSessionInfo,
65 _project: Entity<Project>,
66 _cwd: &Path,
67 _cx: &mut App,
68 ) -> Task<Result<Entity<AcpThread>>> {
69 Task::ready(Err(anyhow::Error::msg(
70 "Resuming sessions is not supported",
71 )))
72 }
73
74 /// Whether this agent supports showing session history.
75 fn supports_session_history(&self, cx: &App) -> bool {
76 self.supports_load_session(cx) || self.supports_resume_session(cx)
77 }
78
79 fn auth_methods(&self) -> &[acp::AuthMethod];
80
81 fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
82
83 fn prompt(
84 &self,
85 user_message_id: Option<UserMessageId>,
86 params: acp::PromptRequest,
87 cx: &mut App,
88 ) -> Task<Result<acp::PromptResponse>>;
89
90 fn retry(&self, _session_id: &acp::SessionId, _cx: &App) -> Option<Rc<dyn AgentSessionRetry>> {
91 None
92 }
93
94 fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
95
96 fn truncate(
97 &self,
98 _session_id: &acp::SessionId,
99 _cx: &App,
100 ) -> Option<Rc<dyn AgentSessionTruncate>> {
101 None
102 }
103
104 fn set_title(
105 &self,
106 _session_id: &acp::SessionId,
107 _cx: &App,
108 ) -> Option<Rc<dyn AgentSessionSetTitle>> {
109 None
110 }
111
112 /// Returns this agent as an [Rc<dyn ModelSelector>] if the model selection capability is supported.
113 ///
114 /// If the agent does not support model selection, returns [None].
115 /// This allows sharing the selector in UI components.
116 fn model_selector(&self, _session_id: &acp::SessionId) -> Option<Rc<dyn AgentModelSelector>> {
117 None
118 }
119
120 fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
121 None
122 }
123
124 fn session_modes(
125 &self,
126 _session_id: &acp::SessionId,
127 _cx: &App,
128 ) -> Option<Rc<dyn AgentSessionModes>> {
129 None
130 }
131
132 fn session_config_options(
133 &self,
134 _session_id: &acp::SessionId,
135 _cx: &App,
136 ) -> Option<Rc<dyn AgentSessionConfigOptions>> {
137 None
138 }
139
140 fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
141 None
142 }
143
144 fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
145}
146
147impl dyn AgentConnection {
148 pub fn downcast<T: 'static + AgentConnection + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
149 self.into_any().downcast().ok()
150 }
151}
152
153pub trait AgentSessionTruncate {
154 fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task<Result<()>>;
155}
156
157pub trait AgentSessionRetry {
158 fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>>;
159}
160
161pub trait AgentSessionSetTitle {
162 fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>>;
163}
164
165pub trait AgentTelemetry {
166 /// A representation of the current thread state that can be serialized for
167 /// storage with telemetry events.
168 fn thread_data(
169 &self,
170 session_id: &acp::SessionId,
171 cx: &mut App,
172 ) -> Task<Result<serde_json::Value>>;
173}
174
175pub trait AgentSessionModes {
176 fn current_mode(&self) -> acp::SessionModeId;
177
178 fn all_modes(&self) -> Vec<acp::SessionMode>;
179
180 fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
181}
182
183pub trait AgentSessionConfigOptions {
184 /// Get all current config options with their state
185 fn config_options(&self) -> Vec<acp::SessionConfigOption>;
186
187 /// Set a config option value
188 /// Returns the full updated list of config options
189 fn set_config_option(
190 &self,
191 config_id: acp::SessionConfigId,
192 value: acp::SessionConfigValueId,
193 cx: &mut App,
194 ) -> Task<Result<Vec<acp::SessionConfigOption>>>;
195
196 /// Whenever the config options are updated the receiver will be notified.
197 /// Optional for agents that don't update their config options dynamically.
198 fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
199 None
200 }
201}
202
203#[derive(Debug, Clone, Default)]
204pub struct AgentSessionListRequest {
205 pub cwd: Option<PathBuf>,
206 pub cursor: Option<String>,
207 pub meta: Option<acp::Meta>,
208}
209
210#[derive(Debug, Clone)]
211pub struct AgentSessionListResponse {
212 pub sessions: Vec<AgentSessionInfo>,
213 pub next_cursor: Option<String>,
214 pub meta: Option<acp::Meta>,
215}
216
217impl AgentSessionListResponse {
218 pub fn new(sessions: Vec<AgentSessionInfo>) -> Self {
219 Self {
220 sessions,
221 next_cursor: None,
222 meta: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq)]
228pub struct AgentSessionInfo {
229 pub session_id: acp::SessionId,
230 pub cwd: Option<PathBuf>,
231 pub title: Option<SharedString>,
232 pub updated_at: Option<DateTime<Utc>>,
233 pub meta: Option<acp::Meta>,
234}
235
236impl AgentSessionInfo {
237 pub fn new(session_id: impl Into<acp::SessionId>) -> Self {
238 Self {
239 session_id: session_id.into(),
240 cwd: None,
241 title: None,
242 updated_at: None,
243 meta: None,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
249pub enum SessionListUpdate {
250 Refresh,
251 SessionInfo {
252 session_id: acp::SessionId,
253 update: acp::SessionInfoUpdate,
254 },
255}
256
257pub trait AgentSessionList {
258 fn list_sessions(
259 &self,
260 request: AgentSessionListRequest,
261 cx: &mut App,
262 ) -> Task<Result<AgentSessionListResponse>>;
263
264 fn supports_delete(&self) -> bool {
265 false
266 }
267
268 fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
269 Task::ready(Err(anyhow::anyhow!("delete_session not supported")))
270 }
271
272 fn delete_sessions(&self, _cx: &mut App) -> Task<Result<()>> {
273 Task::ready(Err(anyhow::anyhow!("delete_sessions not supported")))
274 }
275
276 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
277 None
278 }
279
280 fn notify_refresh(&self) {}
281
282 fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
283}
284
285impl dyn AgentSessionList {
286 pub fn downcast<T: 'static + AgentSessionList + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
287 self.into_any().downcast().ok()
288 }
289}
290
291#[derive(Debug)]
292pub struct AuthRequired {
293 pub description: Option<String>,
294 pub provider_id: Option<LanguageModelProviderId>,
295}
296
297impl AuthRequired {
298 pub fn new() -> Self {
299 Self {
300 description: None,
301 provider_id: None,
302 }
303 }
304
305 pub fn with_description(mut self, description: String) -> Self {
306 self.description = Some(description);
307 self
308 }
309
310 pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self {
311 self.provider_id = Some(provider_id);
312 self
313 }
314}
315
316impl Error for AuthRequired {}
317impl fmt::Display for AuthRequired {
318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319 write!(f, "Authentication required")
320 }
321}
322
323/// Trait for agents that support listing, selecting, and querying language models.
324///
325/// This is an optional capability; agents indicate support via [AgentConnection::model_selector].
326pub trait AgentModelSelector: 'static {
327 /// Lists all available language models for this agent.
328 ///
329 /// # Parameters
330 /// - `cx`: The GPUI app context for async operations and global access.
331 ///
332 /// # Returns
333 /// A task resolving to the list of models or an error (e.g., if no models are configured).
334 fn list_models(&self, cx: &mut App) -> Task<Result<AgentModelList>>;
335
336 /// Selects a model for a specific session (thread).
337 ///
338 /// This sets the default model for future interactions in the session.
339 /// If the session doesn't exist or the model is invalid, it returns an error.
340 ///
341 /// # Parameters
342 /// - `model`: The model to select (should be one from [list_models]).
343 /// - `cx`: The GPUI app context.
344 ///
345 /// # Returns
346 /// A task resolving to `Ok(())` on success or an error.
347 fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task<Result<()>>;
348
349 /// Retrieves the currently selected model for a specific session (thread).
350 ///
351 /// # Parameters
352 /// - `cx`: The GPUI app context.
353 ///
354 /// # Returns
355 /// A task resolving to the selected model (always set) or an error (e.g., session not found).
356 fn selected_model(&self, cx: &mut App) -> Task<Result<AgentModelInfo>>;
357
358 /// Whenever the model list is updated the receiver will be notified.
359 /// Optional for agents that don't update their model list.
360 fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
361 None
362 }
363
364 /// Returns whether the model picker should render a footer.
365 fn should_render_footer(&self) -> bool {
366 false
367 }
368}
369
370/// Icon for a model in the model selector.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum AgentModelIcon {
373 /// A built-in icon from Zed's icon set.
374 Named(IconName),
375 /// Path to a custom SVG icon file.
376 Path(SharedString),
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct AgentModelInfo {
381 pub id: acp::ModelId,
382 pub name: SharedString,
383 pub description: Option<SharedString>,
384 pub icon: Option<AgentModelIcon>,
385 pub is_latest: bool,
386}
387
388impl From<acp::ModelInfo> for AgentModelInfo {
389 fn from(info: acp::ModelInfo) -> Self {
390 Self {
391 id: info.model_id,
392 name: info.name.into(),
393 description: info.description.map(|desc| desc.into()),
394 icon: None,
395 is_latest: false,
396 }
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Hash)]
401pub struct AgentModelGroupName(pub SharedString);
402
403#[derive(Debug, Clone)]
404pub enum AgentModelList {
405 Flat(Vec<AgentModelInfo>),
406 Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
407}
408
409impl AgentModelList {
410 pub fn is_empty(&self) -> bool {
411 match self {
412 AgentModelList::Flat(models) => models.is_empty(),
413 AgentModelList::Grouped(groups) => groups.is_empty(),
414 }
415 }
416
417 pub fn is_flat(&self) -> bool {
418 matches!(self, AgentModelList::Flat(_))
419 }
420}
421
422#[derive(Debug, Clone)]
423pub struct PermissionOptionChoice {
424 pub allow: acp::PermissionOption,
425 pub deny: acp::PermissionOption,
426}
427
428impl PermissionOptionChoice {
429 pub fn label(&self) -> SharedString {
430 self.allow.name.clone().into()
431 }
432}
433
434#[derive(Debug, Clone)]
435pub enum PermissionOptions {
436 Flat(Vec<acp::PermissionOption>),
437 Dropdown(Vec<PermissionOptionChoice>),
438}
439
440impl PermissionOptions {
441 pub fn is_empty(&self) -> bool {
442 match self {
443 PermissionOptions::Flat(options) => options.is_empty(),
444 PermissionOptions::Dropdown(options) => options.is_empty(),
445 }
446 }
447
448 pub fn first_option_of_kind(
449 &self,
450 kind: acp::PermissionOptionKind,
451 ) -> Option<&acp::PermissionOption> {
452 match self {
453 PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind),
454 PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| {
455 if choice.allow.kind == kind {
456 Some(&choice.allow)
457 } else if choice.deny.kind == kind {
458 Some(&choice.deny)
459 } else {
460 None
461 }
462 }),
463 }
464 }
465
466 pub fn allow_once_option_id(&self) -> Option<acp::PermissionOptionId> {
467 self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
468 .map(|option| option.option_id.clone())
469 }
470}
471
472#[cfg(feature = "test-support")]
473mod test_support {
474 //! Test-only stubs and helpers for acp_thread.
475 //!
476 //! This module is gated by the `test-support` feature and is not included
477 //! in production builds. It provides:
478 //! - `StubAgentConnection` for mocking agent connections in tests
479 //! - `create_test_png_base64` for generating test images
480
481 use std::sync::Arc;
482
483 use action_log::ActionLog;
484 use collections::HashMap;
485 use futures::{channel::oneshot, future::try_join_all};
486 use gpui::{AppContext as _, WeakEntity};
487 use parking_lot::Mutex;
488
489 use super::*;
490
491 /// Creates a PNG image encoded as base64 for testing.
492 ///
493 /// Generates a solid-color PNG of the specified dimensions and returns
494 /// it as a base64-encoded string suitable for use in `ImageContent`.
495 pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
496 use image::ImageEncoder as _;
497
498 let mut png_data = Vec::new();
499 {
500 let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
501 let mut pixels = Vec::with_capacity((width * height * 4) as usize);
502 for _ in 0..(width * height) {
503 pixels.extend_from_slice(&color);
504 }
505 encoder
506 .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
507 .expect("Failed to encode PNG");
508 }
509
510 use image::EncodableLayout as _;
511 base64::Engine::encode(
512 &base64::engine::general_purpose::STANDARD,
513 png_data.as_bytes(),
514 )
515 }
516
517 #[derive(Clone, Default)]
518 pub struct StubAgentConnection {
519 sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
520 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
521 next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
522 }
523
524 struct Session {
525 thread: WeakEntity<AcpThread>,
526 response_tx: Option<oneshot::Sender<acp::StopReason>>,
527 }
528
529 impl StubAgentConnection {
530 pub fn new() -> Self {
531 Self {
532 next_prompt_updates: Default::default(),
533 permission_requests: HashMap::default(),
534 sessions: Arc::default(),
535 }
536 }
537
538 pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
539 *self.next_prompt_updates.lock() = updates;
540 }
541
542 pub fn with_permission_requests(
543 mut self,
544 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
545 ) -> Self {
546 self.permission_requests = permission_requests;
547 self
548 }
549
550 pub fn send_update(
551 &self,
552 session_id: acp::SessionId,
553 update: acp::SessionUpdate,
554 cx: &mut App,
555 ) {
556 assert!(
557 self.next_prompt_updates.lock().is_empty(),
558 "Use either send_update or set_next_prompt_updates"
559 );
560
561 self.sessions
562 .lock()
563 .get(&session_id)
564 .unwrap()
565 .thread
566 .update(cx, |thread, cx| {
567 thread.handle_session_update(update, cx).unwrap();
568 })
569 .unwrap();
570 }
571
572 pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
573 self.sessions
574 .lock()
575 .get_mut(&session_id)
576 .unwrap()
577 .response_tx
578 .take()
579 .expect("No pending turn")
580 .send(stop_reason)
581 .unwrap();
582 }
583 }
584
585 impl AgentConnection for StubAgentConnection {
586 fn telemetry_id(&self) -> SharedString {
587 "stub".into()
588 }
589
590 fn auth_methods(&self) -> &[acp::AuthMethod] {
591 &[]
592 }
593
594 fn model_selector(
595 &self,
596 _session_id: &acp::SessionId,
597 ) -> Option<Rc<dyn AgentModelSelector>> {
598 Some(self.model_selector_impl())
599 }
600
601 fn new_thread(
602 self: Rc<Self>,
603 project: Entity<Project>,
604 _cwd: &Path,
605 cx: &mut gpui::App,
606 ) -> Task<gpui::Result<Entity<AcpThread>>> {
607 let session_id = acp::SessionId::new(self.sessions.lock().len().to_string());
608 let action_log = cx.new(|_| ActionLog::new(project.clone()));
609 let thread = cx.new(|cx| {
610 AcpThread::new(
611 "Test",
612 self.clone(),
613 project,
614 action_log,
615 session_id.clone(),
616 watch::Receiver::constant(
617 acp::PromptCapabilities::new()
618 .image(true)
619 .audio(true)
620 .embedded_context(true),
621 ),
622 cx,
623 )
624 });
625 self.sessions.lock().insert(
626 session_id,
627 Session {
628 thread: thread.downgrade(),
629 response_tx: None,
630 },
631 );
632 Task::ready(Ok(thread))
633 }
634
635 fn authenticate(
636 &self,
637 _method_id: acp::AuthMethodId,
638 _cx: &mut App,
639 ) -> Task<gpui::Result<()>> {
640 unimplemented!()
641 }
642
643 fn prompt(
644 &self,
645 _id: Option<UserMessageId>,
646 params: acp::PromptRequest,
647 cx: &mut App,
648 ) -> Task<gpui::Result<acp::PromptResponse>> {
649 let mut sessions = self.sessions.lock();
650 let Session {
651 thread,
652 response_tx,
653 } = sessions.get_mut(¶ms.session_id).unwrap();
654 let mut tasks = vec![];
655 if self.next_prompt_updates.lock().is_empty() {
656 let (tx, rx) = oneshot::channel();
657 response_tx.replace(tx);
658 cx.spawn(async move |_| {
659 let stop_reason = rx.await?;
660 Ok(acp::PromptResponse::new(stop_reason))
661 })
662 } else {
663 for update in self.next_prompt_updates.lock().drain(..) {
664 let thread = thread.clone();
665 let update = update.clone();
666 let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
667 &update
668 && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
669 {
670 Some((tool_call.clone(), options.clone()))
671 } else {
672 None
673 };
674 let task = cx.spawn(async move |cx| {
675 if let Some((tool_call, options)) = permission_request {
676 thread
677 .update(cx, |thread, cx| {
678 thread.request_tool_call_authorization(
679 tool_call.clone().into(),
680 options.clone(),
681 false,
682 cx,
683 )
684 })??
685 .await;
686 }
687 thread.update(cx, |thread, cx| {
688 thread.handle_session_update(update.clone(), cx).unwrap();
689 })?;
690 anyhow::Ok(())
691 });
692 tasks.push(task);
693 }
694
695 cx.spawn(async move |_| {
696 try_join_all(tasks).await?;
697 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
698 })
699 }
700 }
701
702 fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
703 if let Some(end_turn_tx) = self
704 .sessions
705 .lock()
706 .get_mut(session_id)
707 .unwrap()
708 .response_tx
709 .take()
710 {
711 end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
712 }
713 }
714
715 fn truncate(
716 &self,
717 _session_id: &agent_client_protocol::SessionId,
718 _cx: &App,
719 ) -> Option<Rc<dyn AgentSessionTruncate>> {
720 Some(Rc::new(StubAgentSessionEditor))
721 }
722
723 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
724 self
725 }
726 }
727
728 struct StubAgentSessionEditor;
729
730 impl AgentSessionTruncate for StubAgentSessionEditor {
731 fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
732 Task::ready(Ok(()))
733 }
734 }
735
736 #[derive(Clone)]
737 struct StubModelSelector {
738 selected_model: Arc<Mutex<AgentModelInfo>>,
739 }
740
741 impl StubModelSelector {
742 fn new() -> Self {
743 Self {
744 selected_model: Arc::new(Mutex::new(AgentModelInfo {
745 id: acp::ModelId::new("visual-test-model"),
746 name: "Visual Test Model".into(),
747 description: Some("A stub model for visual testing".into()),
748 icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
749 is_latest: false,
750 })),
751 }
752 }
753 }
754
755 impl AgentModelSelector for StubModelSelector {
756 fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
757 let model = self.selected_model.lock().clone();
758 Task::ready(Ok(AgentModelList::Flat(vec![model])))
759 }
760
761 fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
762 self.selected_model.lock().id = model_id;
763 Task::ready(Ok(()))
764 }
765
766 fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
767 Task::ready(Ok(self.selected_model.lock().clone()))
768 }
769 }
770
771 impl StubAgentConnection {
772 /// Returns a model selector for this stub connection.
773 pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
774 Rc::new(StubModelSelector::new())
775 }
776 }
777}
778
779#[cfg(feature = "test-support")]
780pub use test_support::*;