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 into_any(self: Rc<Self>) -> Rc<dyn Any>;
281}
282
283impl dyn AgentSessionList {
284 pub fn downcast<T: 'static + AgentSessionList + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
285 self.into_any().downcast().ok()
286 }
287}
288
289#[derive(Debug)]
290pub struct AuthRequired {
291 pub description: Option<String>,
292 pub provider_id: Option<LanguageModelProviderId>,
293}
294
295impl AuthRequired {
296 pub fn new() -> Self {
297 Self {
298 description: None,
299 provider_id: None,
300 }
301 }
302
303 pub fn with_description(mut self, description: String) -> Self {
304 self.description = Some(description);
305 self
306 }
307
308 pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self {
309 self.provider_id = Some(provider_id);
310 self
311 }
312}
313
314impl Error for AuthRequired {}
315impl fmt::Display for AuthRequired {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 write!(f, "Authentication required")
318 }
319}
320
321/// Trait for agents that support listing, selecting, and querying language models.
322///
323/// This is an optional capability; agents indicate support via [AgentConnection::model_selector].
324pub trait AgentModelSelector: 'static {
325 /// Lists all available language models for this agent.
326 ///
327 /// # Parameters
328 /// - `cx`: The GPUI app context for async operations and global access.
329 ///
330 /// # Returns
331 /// A task resolving to the list of models or an error (e.g., if no models are configured).
332 fn list_models(&self, cx: &mut App) -> Task<Result<AgentModelList>>;
333
334 /// Selects a model for a specific session (thread).
335 ///
336 /// This sets the default model for future interactions in the session.
337 /// If the session doesn't exist or the model is invalid, it returns an error.
338 ///
339 /// # Parameters
340 /// - `model`: The model to select (should be one from [list_models]).
341 /// - `cx`: The GPUI app context.
342 ///
343 /// # Returns
344 /// A task resolving to `Ok(())` on success or an error.
345 fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task<Result<()>>;
346
347 /// Retrieves the currently selected model for a specific session (thread).
348 ///
349 /// # Parameters
350 /// - `cx`: The GPUI app context.
351 ///
352 /// # Returns
353 /// A task resolving to the selected model (always set) or an error (e.g., session not found).
354 fn selected_model(&self, cx: &mut App) -> Task<Result<AgentModelInfo>>;
355
356 /// Whenever the model list is updated the receiver will be notified.
357 /// Optional for agents that don't update their model list.
358 fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
359 None
360 }
361
362 /// Returns whether the model picker should render a footer.
363 fn should_render_footer(&self) -> bool {
364 false
365 }
366}
367
368/// Icon for a model in the model selector.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum AgentModelIcon {
371 /// A built-in icon from Zed's icon set.
372 Named(IconName),
373 /// Path to a custom SVG icon file.
374 Path(SharedString),
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct AgentModelInfo {
379 pub id: acp::ModelId,
380 pub name: SharedString,
381 pub description: Option<SharedString>,
382 pub icon: Option<AgentModelIcon>,
383}
384
385impl From<acp::ModelInfo> for AgentModelInfo {
386 fn from(info: acp::ModelInfo) -> Self {
387 Self {
388 id: info.model_id,
389 name: info.name.into(),
390 description: info.description.map(|desc| desc.into()),
391 icon: None,
392 }
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Hash)]
397pub struct AgentModelGroupName(pub SharedString);
398
399#[derive(Debug, Clone)]
400pub enum AgentModelList {
401 Flat(Vec<AgentModelInfo>),
402 Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
403}
404
405impl AgentModelList {
406 pub fn is_empty(&self) -> bool {
407 match self {
408 AgentModelList::Flat(models) => models.is_empty(),
409 AgentModelList::Grouped(groups) => groups.is_empty(),
410 }
411 }
412
413 pub fn is_flat(&self) -> bool {
414 matches!(self, AgentModelList::Flat(_))
415 }
416}
417
418#[derive(Debug, Clone)]
419pub struct PermissionOptionChoice {
420 pub allow: acp::PermissionOption,
421 pub deny: acp::PermissionOption,
422}
423
424impl PermissionOptionChoice {
425 pub fn label(&self) -> SharedString {
426 self.allow.name.clone().into()
427 }
428}
429
430#[derive(Debug, Clone)]
431pub enum PermissionOptions {
432 Flat(Vec<acp::PermissionOption>),
433 Dropdown(Vec<PermissionOptionChoice>),
434}
435
436impl PermissionOptions {
437 pub fn is_empty(&self) -> bool {
438 match self {
439 PermissionOptions::Flat(options) => options.is_empty(),
440 PermissionOptions::Dropdown(options) => options.is_empty(),
441 }
442 }
443
444 pub fn first_option_of_kind(
445 &self,
446 kind: acp::PermissionOptionKind,
447 ) -> Option<&acp::PermissionOption> {
448 match self {
449 PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind),
450 PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| {
451 if choice.allow.kind == kind {
452 Some(&choice.allow)
453 } else if choice.deny.kind == kind {
454 Some(&choice.deny)
455 } else {
456 None
457 }
458 }),
459 }
460 }
461
462 pub fn allow_once_option_id(&self) -> Option<acp::PermissionOptionId> {
463 self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
464 .map(|option| option.option_id.clone())
465 }
466}
467
468#[cfg(feature = "test-support")]
469mod test_support {
470 //! Test-only stubs and helpers for acp_thread.
471 //!
472 //! This module is gated by the `test-support` feature and is not included
473 //! in production builds. It provides:
474 //! - `StubAgentConnection` for mocking agent connections in tests
475 //! - `create_test_png_base64` for generating test images
476
477 use std::sync::Arc;
478
479 use action_log::ActionLog;
480 use collections::HashMap;
481 use futures::{channel::oneshot, future::try_join_all};
482 use gpui::{AppContext as _, WeakEntity};
483 use parking_lot::Mutex;
484
485 use super::*;
486
487 /// Creates a PNG image encoded as base64 for testing.
488 ///
489 /// Generates a solid-color PNG of the specified dimensions and returns
490 /// it as a base64-encoded string suitable for use in `ImageContent`.
491 pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
492 use image::ImageEncoder as _;
493
494 let mut png_data = Vec::new();
495 {
496 let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
497 let mut pixels = Vec::with_capacity((width * height * 4) as usize);
498 for _ in 0..(width * height) {
499 pixels.extend_from_slice(&color);
500 }
501 encoder
502 .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
503 .expect("Failed to encode PNG");
504 }
505
506 use image::EncodableLayout as _;
507 base64::Engine::encode(
508 &base64::engine::general_purpose::STANDARD,
509 png_data.as_bytes(),
510 )
511 }
512
513 #[derive(Clone, Default)]
514 pub struct StubAgentConnection {
515 sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
516 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
517 next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
518 }
519
520 struct Session {
521 thread: WeakEntity<AcpThread>,
522 response_tx: Option<oneshot::Sender<acp::StopReason>>,
523 }
524
525 impl StubAgentConnection {
526 pub fn new() -> Self {
527 Self {
528 next_prompt_updates: Default::default(),
529 permission_requests: HashMap::default(),
530 sessions: Arc::default(),
531 }
532 }
533
534 pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
535 *self.next_prompt_updates.lock() = updates;
536 }
537
538 pub fn with_permission_requests(
539 mut self,
540 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
541 ) -> Self {
542 self.permission_requests = permission_requests;
543 self
544 }
545
546 pub fn send_update(
547 &self,
548 session_id: acp::SessionId,
549 update: acp::SessionUpdate,
550 cx: &mut App,
551 ) {
552 assert!(
553 self.next_prompt_updates.lock().is_empty(),
554 "Use either send_update or set_next_prompt_updates"
555 );
556
557 self.sessions
558 .lock()
559 .get(&session_id)
560 .unwrap()
561 .thread
562 .update(cx, |thread, cx| {
563 thread.handle_session_update(update, cx).unwrap();
564 })
565 .unwrap();
566 }
567
568 pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
569 self.sessions
570 .lock()
571 .get_mut(&session_id)
572 .unwrap()
573 .response_tx
574 .take()
575 .expect("No pending turn")
576 .send(stop_reason)
577 .unwrap();
578 }
579 }
580
581 impl AgentConnection for StubAgentConnection {
582 fn telemetry_id(&self) -> SharedString {
583 "stub".into()
584 }
585
586 fn auth_methods(&self) -> &[acp::AuthMethod] {
587 &[]
588 }
589
590 fn model_selector(
591 &self,
592 _session_id: &acp::SessionId,
593 ) -> Option<Rc<dyn AgentModelSelector>> {
594 Some(self.model_selector_impl())
595 }
596
597 fn new_thread(
598 self: Rc<Self>,
599 project: Entity<Project>,
600 _cwd: &Path,
601 cx: &mut gpui::App,
602 ) -> Task<gpui::Result<Entity<AcpThread>>> {
603 let session_id = acp::SessionId::new(self.sessions.lock().len().to_string());
604 let action_log = cx.new(|_| ActionLog::new(project.clone()));
605 let thread = cx.new(|cx| {
606 AcpThread::new(
607 "Test",
608 self.clone(),
609 project,
610 action_log,
611 session_id.clone(),
612 watch::Receiver::constant(
613 acp::PromptCapabilities::new()
614 .image(true)
615 .audio(true)
616 .embedded_context(true),
617 ),
618 cx,
619 )
620 });
621 self.sessions.lock().insert(
622 session_id,
623 Session {
624 thread: thread.downgrade(),
625 response_tx: None,
626 },
627 );
628 Task::ready(Ok(thread))
629 }
630
631 fn authenticate(
632 &self,
633 _method_id: acp::AuthMethodId,
634 _cx: &mut App,
635 ) -> Task<gpui::Result<()>> {
636 unimplemented!()
637 }
638
639 fn prompt(
640 &self,
641 _id: Option<UserMessageId>,
642 params: acp::PromptRequest,
643 cx: &mut App,
644 ) -> Task<gpui::Result<acp::PromptResponse>> {
645 let mut sessions = self.sessions.lock();
646 let Session {
647 thread,
648 response_tx,
649 } = sessions.get_mut(¶ms.session_id).unwrap();
650 let mut tasks = vec![];
651 if self.next_prompt_updates.lock().is_empty() {
652 let (tx, rx) = oneshot::channel();
653 response_tx.replace(tx);
654 cx.spawn(async move |_| {
655 let stop_reason = rx.await?;
656 Ok(acp::PromptResponse::new(stop_reason))
657 })
658 } else {
659 for update in self.next_prompt_updates.lock().drain(..) {
660 let thread = thread.clone();
661 let update = update.clone();
662 let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
663 &update
664 && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
665 {
666 Some((tool_call.clone(), options.clone()))
667 } else {
668 None
669 };
670 let task = cx.spawn(async move |cx| {
671 if let Some((tool_call, options)) = permission_request {
672 thread
673 .update(cx, |thread, cx| {
674 thread.request_tool_call_authorization(
675 tool_call.clone().into(),
676 options.clone(),
677 false,
678 cx,
679 )
680 })??
681 .await;
682 }
683 thread.update(cx, |thread, cx| {
684 thread.handle_session_update(update.clone(), cx).unwrap();
685 })?;
686 anyhow::Ok(())
687 });
688 tasks.push(task);
689 }
690
691 cx.spawn(async move |_| {
692 try_join_all(tasks).await?;
693 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
694 })
695 }
696 }
697
698 fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
699 if let Some(end_turn_tx) = self
700 .sessions
701 .lock()
702 .get_mut(session_id)
703 .unwrap()
704 .response_tx
705 .take()
706 {
707 end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
708 }
709 }
710
711 fn truncate(
712 &self,
713 _session_id: &agent_client_protocol::SessionId,
714 _cx: &App,
715 ) -> Option<Rc<dyn AgentSessionTruncate>> {
716 Some(Rc::new(StubAgentSessionEditor))
717 }
718
719 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
720 self
721 }
722 }
723
724 struct StubAgentSessionEditor;
725
726 impl AgentSessionTruncate for StubAgentSessionEditor {
727 fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
728 Task::ready(Ok(()))
729 }
730 }
731
732 #[derive(Clone)]
733 struct StubModelSelector {
734 selected_model: Arc<Mutex<AgentModelInfo>>,
735 }
736
737 impl StubModelSelector {
738 fn new() -> Self {
739 Self {
740 selected_model: Arc::new(Mutex::new(AgentModelInfo {
741 id: acp::ModelId::new("visual-test-model"),
742 name: "Visual Test Model".into(),
743 description: Some("A stub model for visual testing".into()),
744 icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
745 })),
746 }
747 }
748 }
749
750 impl AgentModelSelector for StubModelSelector {
751 fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
752 let model = self.selected_model.lock().clone();
753 Task::ready(Ok(AgentModelList::Flat(vec![model])))
754 }
755
756 fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
757 self.selected_model.lock().id = model_id;
758 Task::ready(Ok(()))
759 }
760
761 fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
762 Task::ready(Ok(self.selected_model.lock().clone()))
763 }
764 }
765
766 impl StubAgentConnection {
767 /// Returns a model selector for this stub connection.
768 pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
769 Rc::new(StubModelSelector::new())
770 }
771 }
772}
773
774#[cfg(feature = "test-support")]
775pub use test_support::*;