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}
386
387impl From<acp::ModelInfo> for AgentModelInfo {
388 fn from(info: acp::ModelInfo) -> Self {
389 Self {
390 id: info.model_id,
391 name: info.name.into(),
392 description: info.description.map(|desc| desc.into()),
393 icon: None,
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Hash)]
399pub struct AgentModelGroupName(pub SharedString);
400
401#[derive(Debug, Clone)]
402pub enum AgentModelList {
403 Flat(Vec<AgentModelInfo>),
404 Grouped(IndexMap<AgentModelGroupName, Vec<AgentModelInfo>>),
405}
406
407impl AgentModelList {
408 pub fn is_empty(&self) -> bool {
409 match self {
410 AgentModelList::Flat(models) => models.is_empty(),
411 AgentModelList::Grouped(groups) => groups.is_empty(),
412 }
413 }
414
415 pub fn is_flat(&self) -> bool {
416 matches!(self, AgentModelList::Flat(_))
417 }
418}
419
420#[derive(Debug, Clone)]
421pub struct PermissionOptionChoice {
422 pub allow: acp::PermissionOption,
423 pub deny: acp::PermissionOption,
424}
425
426impl PermissionOptionChoice {
427 pub fn label(&self) -> SharedString {
428 self.allow.name.clone().into()
429 }
430}
431
432#[derive(Debug, Clone)]
433pub enum PermissionOptions {
434 Flat(Vec<acp::PermissionOption>),
435 Dropdown(Vec<PermissionOptionChoice>),
436}
437
438impl PermissionOptions {
439 pub fn is_empty(&self) -> bool {
440 match self {
441 PermissionOptions::Flat(options) => options.is_empty(),
442 PermissionOptions::Dropdown(options) => options.is_empty(),
443 }
444 }
445
446 pub fn first_option_of_kind(
447 &self,
448 kind: acp::PermissionOptionKind,
449 ) -> Option<&acp::PermissionOption> {
450 match self {
451 PermissionOptions::Flat(options) => options.iter().find(|option| option.kind == kind),
452 PermissionOptions::Dropdown(options) => options.iter().find_map(|choice| {
453 if choice.allow.kind == kind {
454 Some(&choice.allow)
455 } else if choice.deny.kind == kind {
456 Some(&choice.deny)
457 } else {
458 None
459 }
460 }),
461 }
462 }
463
464 pub fn allow_once_option_id(&self) -> Option<acp::PermissionOptionId> {
465 self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
466 .map(|option| option.option_id.clone())
467 }
468}
469
470#[cfg(feature = "test-support")]
471mod test_support {
472 //! Test-only stubs and helpers for acp_thread.
473 //!
474 //! This module is gated by the `test-support` feature and is not included
475 //! in production builds. It provides:
476 //! - `StubAgentConnection` for mocking agent connections in tests
477 //! - `create_test_png_base64` for generating test images
478
479 use std::sync::Arc;
480
481 use action_log::ActionLog;
482 use collections::HashMap;
483 use futures::{channel::oneshot, future::try_join_all};
484 use gpui::{AppContext as _, WeakEntity};
485 use parking_lot::Mutex;
486
487 use super::*;
488
489 /// Creates a PNG image encoded as base64 for testing.
490 ///
491 /// Generates a solid-color PNG of the specified dimensions and returns
492 /// it as a base64-encoded string suitable for use in `ImageContent`.
493 pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
494 use image::ImageEncoder as _;
495
496 let mut png_data = Vec::new();
497 {
498 let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
499 let mut pixels = Vec::with_capacity((width * height * 4) as usize);
500 for _ in 0..(width * height) {
501 pixels.extend_from_slice(&color);
502 }
503 encoder
504 .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
505 .expect("Failed to encode PNG");
506 }
507
508 use image::EncodableLayout as _;
509 base64::Engine::encode(
510 &base64::engine::general_purpose::STANDARD,
511 png_data.as_bytes(),
512 )
513 }
514
515 #[derive(Clone, Default)]
516 pub struct StubAgentConnection {
517 sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
518 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
519 next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
520 }
521
522 struct Session {
523 thread: WeakEntity<AcpThread>,
524 response_tx: Option<oneshot::Sender<acp::StopReason>>,
525 }
526
527 impl StubAgentConnection {
528 pub fn new() -> Self {
529 Self {
530 next_prompt_updates: Default::default(),
531 permission_requests: HashMap::default(),
532 sessions: Arc::default(),
533 }
534 }
535
536 pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
537 *self.next_prompt_updates.lock() = updates;
538 }
539
540 pub fn with_permission_requests(
541 mut self,
542 permission_requests: HashMap<acp::ToolCallId, PermissionOptions>,
543 ) -> Self {
544 self.permission_requests = permission_requests;
545 self
546 }
547
548 pub fn send_update(
549 &self,
550 session_id: acp::SessionId,
551 update: acp::SessionUpdate,
552 cx: &mut App,
553 ) {
554 assert!(
555 self.next_prompt_updates.lock().is_empty(),
556 "Use either send_update or set_next_prompt_updates"
557 );
558
559 self.sessions
560 .lock()
561 .get(&session_id)
562 .unwrap()
563 .thread
564 .update(cx, |thread, cx| {
565 thread.handle_session_update(update, cx).unwrap();
566 })
567 .unwrap();
568 }
569
570 pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) {
571 self.sessions
572 .lock()
573 .get_mut(&session_id)
574 .unwrap()
575 .response_tx
576 .take()
577 .expect("No pending turn")
578 .send(stop_reason)
579 .unwrap();
580 }
581 }
582
583 impl AgentConnection for StubAgentConnection {
584 fn telemetry_id(&self) -> SharedString {
585 "stub".into()
586 }
587
588 fn auth_methods(&self) -> &[acp::AuthMethod] {
589 &[]
590 }
591
592 fn model_selector(
593 &self,
594 _session_id: &acp::SessionId,
595 ) -> Option<Rc<dyn AgentModelSelector>> {
596 Some(self.model_selector_impl())
597 }
598
599 fn new_thread(
600 self: Rc<Self>,
601 project: Entity<Project>,
602 _cwd: &Path,
603 cx: &mut gpui::App,
604 ) -> Task<gpui::Result<Entity<AcpThread>>> {
605 let session_id = acp::SessionId::new(self.sessions.lock().len().to_string());
606 let action_log = cx.new(|_| ActionLog::new(project.clone()));
607 let thread = cx.new(|cx| {
608 AcpThread::new(
609 "Test",
610 self.clone(),
611 project,
612 action_log,
613 session_id.clone(),
614 watch::Receiver::constant(
615 acp::PromptCapabilities::new()
616 .image(true)
617 .audio(true)
618 .embedded_context(true),
619 ),
620 cx,
621 )
622 });
623 self.sessions.lock().insert(
624 session_id,
625 Session {
626 thread: thread.downgrade(),
627 response_tx: None,
628 },
629 );
630 Task::ready(Ok(thread))
631 }
632
633 fn authenticate(
634 &self,
635 _method_id: acp::AuthMethodId,
636 _cx: &mut App,
637 ) -> Task<gpui::Result<()>> {
638 unimplemented!()
639 }
640
641 fn prompt(
642 &self,
643 _id: Option<UserMessageId>,
644 params: acp::PromptRequest,
645 cx: &mut App,
646 ) -> Task<gpui::Result<acp::PromptResponse>> {
647 let mut sessions = self.sessions.lock();
648 let Session {
649 thread,
650 response_tx,
651 } = sessions.get_mut(¶ms.session_id).unwrap();
652 let mut tasks = vec![];
653 if self.next_prompt_updates.lock().is_empty() {
654 let (tx, rx) = oneshot::channel();
655 response_tx.replace(tx);
656 cx.spawn(async move |_| {
657 let stop_reason = rx.await?;
658 Ok(acp::PromptResponse::new(stop_reason))
659 })
660 } else {
661 for update in self.next_prompt_updates.lock().drain(..) {
662 let thread = thread.clone();
663 let update = update.clone();
664 let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
665 &update
666 && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id)
667 {
668 Some((tool_call.clone(), options.clone()))
669 } else {
670 None
671 };
672 let task = cx.spawn(async move |cx| {
673 if let Some((tool_call, options)) = permission_request {
674 thread
675 .update(cx, |thread, cx| {
676 thread.request_tool_call_authorization(
677 tool_call.clone().into(),
678 options.clone(),
679 false,
680 cx,
681 )
682 })??
683 .await;
684 }
685 thread.update(cx, |thread, cx| {
686 thread.handle_session_update(update.clone(), cx).unwrap();
687 })?;
688 anyhow::Ok(())
689 });
690 tasks.push(task);
691 }
692
693 cx.spawn(async move |_| {
694 try_join_all(tasks).await?;
695 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
696 })
697 }
698 }
699
700 fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
701 if let Some(end_turn_tx) = self
702 .sessions
703 .lock()
704 .get_mut(session_id)
705 .unwrap()
706 .response_tx
707 .take()
708 {
709 end_turn_tx.send(acp::StopReason::Cancelled).unwrap();
710 }
711 }
712
713 fn truncate(
714 &self,
715 _session_id: &agent_client_protocol::SessionId,
716 _cx: &App,
717 ) -> Option<Rc<dyn AgentSessionTruncate>> {
718 Some(Rc::new(StubAgentSessionEditor))
719 }
720
721 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
722 self
723 }
724 }
725
726 struct StubAgentSessionEditor;
727
728 impl AgentSessionTruncate for StubAgentSessionEditor {
729 fn run(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
730 Task::ready(Ok(()))
731 }
732 }
733
734 #[derive(Clone)]
735 struct StubModelSelector {
736 selected_model: Arc<Mutex<AgentModelInfo>>,
737 }
738
739 impl StubModelSelector {
740 fn new() -> Self {
741 Self {
742 selected_model: Arc::new(Mutex::new(AgentModelInfo {
743 id: acp::ModelId::new("visual-test-model"),
744 name: "Visual Test Model".into(),
745 description: Some("A stub model for visual testing".into()),
746 icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
747 })),
748 }
749 }
750 }
751
752 impl AgentModelSelector for StubModelSelector {
753 fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
754 let model = self.selected_model.lock().clone();
755 Task::ready(Ok(AgentModelList::Flat(vec![model])))
756 }
757
758 fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
759 self.selected_model.lock().id = model_id;
760 Task::ready(Ok(()))
761 }
762
763 fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
764 Task::ready(Ok(self.selected_model.lock().clone()))
765 }
766 }
767
768 impl StubAgentConnection {
769 /// Returns a model selector for this stub connection.
770 pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
771 Rc::new(StubModelSelector::new())
772 }
773 }
774}
775
776#[cfg(feature = "test-support")]
777pub use test_support::*;