1use acp_thread::{
2 AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
3 AssistantMessageChunk, AuthRequired, LoadError, MaxOutputTokensError, MentionUri,
4 PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus,
5 SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
6 UserMessageId,
7};
8use acp_thread::{AgentConnection, Plan};
9use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
10use agent::{
11 NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore,
12};
13use agent_client_protocol as acp;
14#[cfg(test)]
15use agent_servers::AgentServerDelegate;
16use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID};
17use agent_settings::{AgentProfileId, AgentSettings};
18use anyhow::{Result, anyhow};
19#[cfg(feature = "audio")]
20use audio::{Audio, Sound};
21use buffer_diff::BufferDiff;
22use client::zed_urls;
23use collections::{HashMap, HashSet, IndexMap};
24use editor::scroll::Autoscroll;
25use editor::{
26 Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
27};
28use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _};
29use file_icons::FileIcons;
30use fs::Fs;
31use futures::FutureExt as _;
32use gpui::{
33 Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
34 ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
35 ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
36 WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
37 list, point, pulsating_between,
38};
39use language::Buffer;
40use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
41use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
42use parking_lot::RwLock;
43use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
44use prompt_store::{PromptId, PromptStore};
45
46use crate::DEFAULT_THREAD_TITLE;
47use crate::message_editor::SessionCapabilities;
48use rope::Point;
49use settings::{
50 NewThreadLocation, NotifyWhenAgentWaiting, Settings as _, SettingsStore, SidebarSide,
51 ThinkingBlockDisplay,
52};
53use std::path::Path;
54use std::sync::Arc;
55use std::time::Instant;
56use std::{collections::BTreeMap, rc::Rc, time::Duration};
57use terminal_view::terminal_panel::TerminalPanel;
58use text::Anchor;
59use theme_settings::AgentFontSize;
60use ui::{
61 Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
62 DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind,
63 KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
64 right_click_menu,
65};
66use util::{ResultExt, size::format_file_size, time::duration_alt_display};
67use util::{debug_panic, defer};
68use workspace::PathList;
69use workspace::{
70 CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
71};
72use zed_actions::agent::{Chat, ToggleModelSelector};
73use zed_actions::assistant::OpenRulesLibrary;
74
75use super::config_options::ConfigOptionsView;
76use super::entry_view_state::EntryViewState;
77use super::thread_history::ThreadHistory;
78use crate::ModeSelector;
79use crate::ModelSelectorPopover;
80use crate::agent_connection_store::{
81 AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
82};
83use crate::agent_diff::AgentDiff;
84use crate::entry_view_state::{EntryViewEvent, ViewEvent};
85use crate::message_editor::{MessageEditor, MessageEditorEvent};
86use crate::profile_selector::{ProfileProvider, ProfileSelector};
87
88use crate::thread_metadata_store::ThreadId;
89use crate::ui::{AgentNotification, AgentNotificationEvent};
90use crate::{
91 Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
92 AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
93 CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
94 OpenAddContextMenu, OpenAgentDiff, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
95 ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, ScrollOutputPageUp,
96 ScrollOutputToBottom, ScrollOutputToNextMessage, ScrollOutputToPreviousMessage,
97 ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
98 ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
99};
100
101const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
102const TOKEN_THRESHOLD: u64 = 250;
103
104mod thread_view;
105pub use thread_view::*;
106
107pub struct QueuedMessage {
108 pub content: Vec<acp::ContentBlock>,
109 pub tracked_buffers: Vec<Entity<Buffer>>,
110}
111
112#[derive(Copy, Clone, Debug, PartialEq, Eq)]
113enum ThreadFeedback {
114 Positive,
115 Negative,
116}
117
118#[derive(Debug)]
119pub(crate) enum ThreadError {
120 PaymentRequired,
121 Refusal,
122 AuthenticationRequired(SharedString),
123 RateLimitExceeded {
124 provider: SharedString,
125 },
126 ServerOverloaded {
127 provider: SharedString,
128 },
129 PromptTooLarge,
130 NoApiKey {
131 provider: SharedString,
132 },
133 StreamError {
134 provider: SharedString,
135 },
136 InvalidApiKey {
137 provider: SharedString,
138 },
139 PermissionDenied {
140 provider: SharedString,
141 },
142 RequestFailed,
143 MaxOutputTokens,
144 NoModelSelected,
145 ApiError {
146 provider: SharedString,
147 },
148 Other {
149 message: SharedString,
150 acp_error_code: Option<SharedString>,
151 },
152}
153
154impl From<anyhow::Error> for ThreadError {
155 fn from(error: anyhow::Error) -> Self {
156 if error.is::<MaxOutputTokensError>() {
157 Self::MaxOutputTokens
158 } else if error.is::<NoModelConfiguredError>() {
159 Self::NoModelSelected
160 } else if error.is::<language_model::PaymentRequiredError>() {
161 Self::PaymentRequired
162 } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
163 && acp_error.code == acp::ErrorCode::AuthRequired
164 {
165 Self::AuthenticationRequired(acp_error.message.clone().into())
166 } else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
167 use LanguageModelCompletionError::*;
168 match lm_error {
169 RateLimitExceeded { provider, .. } => Self::RateLimitExceeded {
170 provider: provider.to_string().into(),
171 },
172 ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => {
173 Self::ServerOverloaded {
174 provider: provider.to_string().into(),
175 }
176 }
177 PromptTooLarge { .. } => Self::PromptTooLarge,
178 NoApiKey { provider } => Self::NoApiKey {
179 provider: provider.to_string().into(),
180 },
181 StreamEndedUnexpectedly { provider }
182 | ApiReadResponseError { provider, .. }
183 | DeserializeResponse { provider, .. }
184 | HttpSend { provider, .. } => Self::StreamError {
185 provider: provider.to_string().into(),
186 },
187 AuthenticationError { provider, .. } => Self::InvalidApiKey {
188 provider: provider.to_string().into(),
189 },
190 PermissionError { provider, .. } => Self::PermissionDenied {
191 provider: provider.to_string().into(),
192 },
193 UpstreamProviderError { .. } => Self::RequestFailed,
194 BadRequestFormat { provider, .. }
195 | HttpResponseError { provider, .. }
196 | ApiEndpointNotFound { provider } => Self::ApiError {
197 provider: provider.to_string().into(),
198 },
199 _ => {
200 let message: SharedString = format!("{:#}", error).into();
201 Self::Other {
202 message,
203 acp_error_code: None,
204 }
205 }
206 }
207 } else {
208 let message: SharedString = format!("{:#}", error).into();
209
210 // Extract ACP error code if available
211 let acp_error_code = error
212 .downcast_ref::<acp::Error>()
213 .map(|acp_error| SharedString::from(acp_error.code.to_string()));
214
215 Self::Other {
216 message,
217 acp_error_code,
218 }
219 }
220 }
221}
222
223impl ProfileProvider for Entity<agent::Thread> {
224 fn profile_id(&self, cx: &App) -> AgentProfileId {
225 self.read(cx).profile().clone()
226 }
227
228 fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
229 self.update(cx, |thread, cx| {
230 // Apply the profile and let the thread swap to its default model.
231 thread.set_profile(profile_id, cx);
232 });
233 }
234
235 fn profiles_supported(&self, cx: &App) -> bool {
236 self.read(cx)
237 .model()
238 .is_some_and(|model| model.supports_tools())
239 }
240
241 fn model_selected(&self, cx: &App) -> bool {
242 self.read(cx).model().is_some()
243 }
244}
245
246#[derive(Default)]
247pub(crate) struct Conversation {
248 threads: HashMap<acp::SessionId, Entity<AcpThread>>,
249 permission_requests: IndexMap<acp::SessionId, Vec<acp::ToolCallId>>,
250 subscriptions: Vec<Subscription>,
251 updated_at: Option<Instant>,
252}
253
254impl Conversation {
255 pub fn register_thread(&mut self, thread: Entity<AcpThread>, cx: &mut Context<Self>) {
256 let session_id = thread.read(cx).session_id().clone();
257 let subscription = cx.subscribe(&thread, {
258 let session_id = session_id.clone();
259 move |this, _thread, event, _cx| {
260 this.updated_at = Some(Instant::now());
261 match event {
262 AcpThreadEvent::ToolAuthorizationRequested(id) => {
263 this.permission_requests
264 .entry(session_id.clone())
265 .or_default()
266 .push(id.clone());
267 }
268 AcpThreadEvent::ToolAuthorizationReceived(id) => {
269 if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) {
270 tool_calls.retain(|tool_call_id| tool_call_id != id);
271 if tool_calls.is_empty() {
272 this.permission_requests.shift_remove(&session_id);
273 }
274 }
275 }
276 AcpThreadEvent::NewEntry
277 | AcpThreadEvent::TitleUpdated
278 | AcpThreadEvent::TokenUsageUpdated
279 | AcpThreadEvent::EntryUpdated(_)
280 | AcpThreadEvent::EntriesRemoved(_)
281 | AcpThreadEvent::Retry(_)
282 | AcpThreadEvent::SubagentSpawned(_)
283 | AcpThreadEvent::Stopped(_)
284 | AcpThreadEvent::Error
285 | AcpThreadEvent::LoadError(_)
286 | AcpThreadEvent::PromptCapabilitiesUpdated
287 | AcpThreadEvent::Refusal
288 | AcpThreadEvent::AvailableCommandsUpdated(_)
289 | AcpThreadEvent::ModeUpdated(_)
290 | AcpThreadEvent::ConfigOptionsUpdated(_)
291 | AcpThreadEvent::WorkingDirectoriesUpdated
292 | AcpThreadEvent::PromptUpdated => {}
293 }
294 }
295 });
296 self.subscriptions.push(subscription);
297 self.threads.insert(session_id, thread);
298 }
299
300 pub fn pending_tool_call<'a>(
301 &'a self,
302 session_id: &acp::SessionId,
303 cx: &'a App,
304 ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
305 let thread = self.threads.get(session_id)?;
306 let is_subagent = thread.read(cx).parent_session_id().is_some();
307 let (result_session_id, thread, tool_id) = if is_subagent {
308 let id = self.permission_requests.get(session_id)?.iter().next()?;
309 (session_id.clone(), thread, id)
310 } else {
311 let (id, tool_calls) = self.permission_requests.first()?;
312 let thread = self.threads.get(id)?;
313 let tool_id = tool_calls.iter().next()?;
314 (id.clone(), thread, tool_id)
315 };
316 let (_, tool_call) = thread.read(cx).tool_call(tool_id)?;
317
318 let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
319 return None;
320 };
321 Some((result_session_id, tool_id.clone(), options))
322 }
323
324 pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> {
325 self.permission_requests
326 .iter()
327 .filter_map(|(session_id, tool_call_ids)| {
328 let thread = self.threads.get(session_id)?;
329 if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() {
330 Some((session_id.clone(), tool_call_ids.len()))
331 } else {
332 None
333 }
334 })
335 .collect()
336 }
337
338 pub fn authorize_pending_tool_call(
339 &mut self,
340 session_id: &acp::SessionId,
341 kind: acp::PermissionOptionKind,
342 cx: &mut Context<Self>,
343 ) -> Option<()> {
344 let (authorize_session_id, tool_call_id, options) =
345 self.pending_tool_call(session_id, cx)?;
346 let option = options.first_option_of_kind(kind)?;
347 self.authorize_tool_call(
348 authorize_session_id,
349 tool_call_id,
350 SelectedPermissionOutcome::new(option.option_id.clone(), option.kind),
351 cx,
352 );
353 Some(())
354 }
355
356 pub fn authorize_tool_call(
357 &mut self,
358 session_id: acp::SessionId,
359 tool_call_id: acp::ToolCallId,
360 outcome: SelectedPermissionOutcome,
361 cx: &mut Context<Self>,
362 ) {
363 let Some(thread) = self.threads.get(&session_id) else {
364 return;
365 };
366 let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
367 let session_id = thread.read(cx).session_id().clone();
368
369 telemetry::event!(
370 "Agent Tool Call Authorized",
371 agent = agent_telemetry_id,
372 session = session_id,
373 option = outcome.option_kind
374 );
375
376 thread.update(cx, |thread, cx| {
377 thread.authorize_tool_call(tool_call_id, outcome, cx);
378 });
379 cx.notify();
380 }
381
382 fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
383 for thread in self.threads.values() {
384 thread.update(cx, |thread, cx| {
385 thread.set_work_dirs(work_dirs.clone(), cx);
386 });
387 }
388 }
389}
390
391pub(crate) struct RootThreadUpdated;
392
393impl EventEmitter<RootThreadUpdated> for ConversationView {}
394
395fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
396 match event {
397 AcpThreadEvent::NewEntry
398 | AcpThreadEvent::TitleUpdated
399 | AcpThreadEvent::ToolAuthorizationRequested(_)
400 | AcpThreadEvent::ToolAuthorizationReceived(_)
401 | AcpThreadEvent::Stopped(_)
402 | AcpThreadEvent::Error
403 | AcpThreadEvent::LoadError(_)
404 | AcpThreadEvent::Refusal
405 | AcpThreadEvent::WorkingDirectoriesUpdated => true,
406 // --
407 AcpThreadEvent::EntryUpdated(_)
408 | AcpThreadEvent::EntriesRemoved(_)
409 | AcpThreadEvent::Retry(_)
410 | AcpThreadEvent::TokenUsageUpdated
411 | AcpThreadEvent::PromptCapabilitiesUpdated
412 | AcpThreadEvent::AvailableCommandsUpdated(_)
413 | AcpThreadEvent::ModeUpdated(_)
414 | AcpThreadEvent::ConfigOptionsUpdated(_)
415 | AcpThreadEvent::SubagentSpawned(_)
416 | AcpThreadEvent::PromptUpdated => false,
417 }
418}
419
420pub enum AcpServerViewEvent {
421 ActiveThreadChanged,
422}
423
424impl EventEmitter<AcpServerViewEvent> for ConversationView {}
425
426pub struct ConversationView {
427 agent: Rc<dyn AgentServer>,
428 connection_store: Entity<AgentConnectionStore>,
429 connection_key: Agent,
430 agent_server_store: Entity<AgentServerStore>,
431 workspace: WeakEntity<Workspace>,
432 project: Entity<Project>,
433 thread_store: Option<Entity<ThreadStore>>,
434 prompt_store: Option<Entity<PromptStore>>,
435 pub(crate) thread_id: ThreadId,
436 pub(crate) root_session_id: Option<acp::SessionId>,
437 server_state: ServerState,
438 focus_handle: FocusHandle,
439 notifications: Vec<WindowHandle<AgentNotification>>,
440 notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
441 auth_task: Option<Task<()>>,
442 _subscriptions: Vec<Subscription>,
443}
444
445impl ConversationView {
446 pub fn has_auth_methods(&self) -> bool {
447 self.as_connected().map_or(false, |connected| {
448 !connected.connection.auth_methods().is_empty()
449 })
450 }
451
452 pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
453 match &self.server_state {
454 ServerState::Connected(connected) => connected.active_view(),
455 _ => None,
456 }
457 }
458
459 pub fn pending_tool_call<'a>(
460 &'a self,
461 cx: &'a App,
462 ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
463 let session_id = self.active_thread()?.read(cx).session_id.clone();
464 self.as_connected()?
465 .conversation
466 .read(cx)
467 .pending_tool_call(&session_id, cx)
468 }
469
470 pub fn root_thread_has_pending_tool_call(&self, cx: &App) -> bool {
471 let Some(root_thread) = self.root_thread_view() else {
472 return false;
473 };
474 let root_session_id = root_thread.read(cx).thread.read(cx).session_id().clone();
475 self.as_connected().is_some_and(|connected| {
476 connected
477 .conversation
478 .read(cx)
479 .pending_tool_call(&root_session_id, cx)
480 .is_some()
481 })
482 }
483
484 pub(crate) fn root_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
485 self.root_thread_view()
486 .map(|view| view.read(cx).thread.clone())
487 }
488
489 pub fn root_thread_view(&self) -> Option<Entity<ThreadView>> {
490 self.root_session_id
491 .as_ref()
492 .and_then(|id| self.thread_view(id))
493 }
494
495 pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<ThreadView>> {
496 let connected = self.as_connected()?;
497 connected.threads.get(session_id).cloned()
498 }
499
500 pub fn as_connected(&self) -> Option<&ConnectedServerState> {
501 match &self.server_state {
502 ServerState::Connected(connected) => Some(connected),
503 _ => None,
504 }
505 }
506
507 pub fn as_connected_mut(&mut self) -> Option<&mut ConnectedServerState> {
508 match &mut self.server_state {
509 ServerState::Connected(connected) => Some(connected),
510 _ => None,
511 }
512 }
513
514 pub fn updated_at(&self, cx: &App) -> Option<Instant> {
515 self.as_connected()
516 .and_then(|connected| connected.conversation.read(cx).updated_at)
517 }
518
519 pub fn navigate_to_thread(
520 &mut self,
521 session_id: acp::SessionId,
522 window: &mut Window,
523 cx: &mut Context<Self>,
524 ) {
525 let Some(connected) = self.as_connected_mut() else {
526 return;
527 };
528
529 connected.navigate_to_thread(session_id);
530 if let Some(view) = self.active_thread() {
531 view.focus_handle(cx).focus(window, cx);
532 }
533 cx.emit(AcpServerViewEvent::ActiveThreadChanged);
534 cx.notify();
535 }
536
537 pub fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
538 if let Some(connected) = self.as_connected() {
539 connected.conversation.update(cx, |conversation, cx| {
540 conversation.set_work_dirs(work_dirs.clone(), cx);
541 });
542 }
543 }
544}
545
546enum ServerState {
547 Loading { _loading: Entity<LoadingView> },
548 LoadError { error: LoadError },
549 Connected(ConnectedServerState),
550}
551
552// current -> Entity
553// hashmap of threads, current becomes session_id
554pub struct ConnectedServerState {
555 auth_state: AuthState,
556 active_id: Option<acp::SessionId>,
557 pub(crate) threads: HashMap<acp::SessionId, Entity<ThreadView>>,
558 connection: Rc<dyn AgentConnection>,
559 history: Option<Entity<ThreadHistory>>,
560 conversation: Entity<Conversation>,
561 _connection_entry_subscription: Subscription,
562}
563
564enum AuthState {
565 Ok,
566 Unauthenticated {
567 description: Option<Entity<Markdown>>,
568 configuration_view: Option<AnyView>,
569 pending_auth_method: Option<acp::AuthMethodId>,
570 _subscription: Option<Subscription>,
571 },
572}
573
574impl AuthState {
575 pub fn is_ok(&self) -> bool {
576 matches!(self, Self::Ok)
577 }
578}
579
580struct LoadingView {
581 _load_task: Task<()>,
582}
583
584impl ConnectedServerState {
585 pub fn active_view(&self) -> Option<&Entity<ThreadView>> {
586 self.active_id.as_ref().and_then(|id| self.threads.get(id))
587 }
588
589 pub fn has_thread_error(&self, cx: &App) -> bool {
590 self.active_view()
591 .map_or(false, |view| view.read(cx).thread_error.is_some())
592 }
593
594 pub fn navigate_to_thread(&mut self, session_id: acp::SessionId) {
595 if self.threads.contains_key(&session_id) {
596 self.active_id = Some(session_id);
597 }
598 }
599
600 pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
601 let tasks = self.threads.values().filter_map(|view| {
602 if self.connection.supports_close_session() {
603 let session_id = view.read(cx).thread.read(cx).session_id().clone();
604 Some(self.connection.clone().close_session(&session_id, cx))
605 } else {
606 None
607 }
608 });
609 let task = futures::future::join_all(tasks);
610 cx.background_spawn(async move {
611 task.await;
612 })
613 }
614}
615
616impl ConversationView {
617 pub fn new(
618 agent: Rc<dyn AgentServer>,
619 connection_store: Entity<AgentConnectionStore>,
620 connection_key: Agent,
621 resume_session_id: Option<acp::SessionId>,
622 thread_id: Option<ThreadId>,
623 work_dirs: Option<PathList>,
624 title: Option<SharedString>,
625 initial_content: Option<AgentInitialContent>,
626 workspace: WeakEntity<Workspace>,
627 project: Entity<Project>,
628 thread_store: Option<Entity<ThreadStore>>,
629 prompt_store: Option<Entity<PromptStore>>,
630 source: &'static str,
631 window: &mut Window,
632 cx: &mut Context<Self>,
633 ) -> Self {
634 let agent_server_store = project.read(cx).agent_server_store().clone();
635 let subscriptions = vec![
636 cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
637 cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
638 cx.subscribe_in(
639 &agent_server_store,
640 window,
641 Self::handle_agent_servers_updated,
642 ),
643 ];
644
645 cx.on_release(|this, cx| {
646 if let Some(connected) = this.as_connected() {
647 connected.close_all_sessions(cx).detach();
648 }
649 for window in this.notifications.drain(..) {
650 window
651 .update(cx, |_, window, _| {
652 window.remove_window();
653 })
654 .ok();
655 }
656 })
657 .detach();
658
659 let thread_id = thread_id.unwrap_or_else(ThreadId::new);
660
661 Self {
662 agent: agent.clone(),
663 connection_store: connection_store.clone(),
664 connection_key: connection_key.clone(),
665 agent_server_store,
666 workspace,
667 project: project.clone(),
668 thread_store,
669 prompt_store,
670 thread_id,
671 root_session_id: resume_session_id.clone(),
672 server_state: Self::initial_state(
673 agent.clone(),
674 connection_store,
675 connection_key,
676 resume_session_id,
677 work_dirs,
678 title,
679 project,
680 initial_content,
681 source,
682 window,
683 cx,
684 ),
685 notifications: Vec::new(),
686 notification_subscriptions: HashMap::default(),
687 auth_task: None,
688 _subscriptions: subscriptions,
689 focus_handle: cx.focus_handle(),
690 }
691 }
692
693 fn set_server_state(&mut self, state: ServerState, cx: &mut Context<Self>) {
694 if let Some(connected) = self.as_connected() {
695 connected.close_all_sessions(cx).detach();
696 }
697
698 self.server_state = state;
699 cx.emit(AcpServerViewEvent::ActiveThreadChanged);
700 cx.notify();
701 }
702
703 fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
704 let (resume_session_id, cwd, title) = self
705 .root_thread_view()
706 .map(|thread_view| {
707 let tv = thread_view.read(cx);
708 let thread = tv.thread.read(cx);
709 (
710 Some(thread.session_id().clone()),
711 thread.work_dirs().cloned(),
712 thread.title(),
713 )
714 })
715 .unwrap_or((None, None, None));
716
717 let state = Self::initial_state(
718 self.agent.clone(),
719 self.connection_store.clone(),
720 self.connection_key.clone(),
721 resume_session_id,
722 cwd,
723 title,
724 self.project.clone(),
725 None,
726 "agent_panel",
727 window,
728 cx,
729 );
730 self.set_server_state(state, cx);
731
732 if let Some(view) = self.root_thread_view() {
733 view.update(cx, |this, cx| {
734 this.message_editor.update(cx, |editor, cx| {
735 editor.set_session_capabilities(this.session_capabilities.clone(), cx);
736 });
737 });
738 }
739 cx.notify();
740 }
741
742 fn initial_state(
743 agent: Rc<dyn AgentServer>,
744 connection_store: Entity<AgentConnectionStore>,
745 connection_key: Agent,
746 resume_session_id: Option<acp::SessionId>,
747 work_dirs: Option<PathList>,
748 title: Option<SharedString>,
749 project: Entity<Project>,
750 initial_content: Option<AgentInitialContent>,
751 source: &'static str,
752 window: &mut Window,
753 cx: &mut Context<Self>,
754 ) -> ServerState {
755 if project.read(cx).is_via_collab()
756 && agent.clone().downcast::<NativeAgentServer>().is_none()
757 {
758 return ServerState::LoadError {
759 error: LoadError::Other(
760 "External agents are not yet supported in shared projects.".into(),
761 ),
762 };
763 }
764 let session_work_dirs = work_dirs.unwrap_or_else(|| project.read(cx).default_path_list(cx));
765
766 let connection_entry = connection_store.update(cx, |store, cx| {
767 store.request_connection(connection_key, agent.clone(), cx)
768 });
769
770 let connection_entry_subscription =
771 cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
772 AgentConnectionEntryEvent::NewVersionAvailable(version) => {
773 if let Some(thread) = this.root_thread_view() {
774 thread.update(cx, |thread, cx| {
775 thread.new_server_version_available = Some(version.clone());
776 cx.notify();
777 });
778 }
779 }
780 });
781
782 let connect_result = connection_entry.read(cx).wait_for_connection();
783
784 let side = match AgentSettings::get_global(cx).sidebar_side() {
785 SidebarSide::Left => "left",
786 SidebarSide::Right => "right",
787 };
788 let thread_location = match AgentSettings::get_global(cx).new_thread_location {
789 NewThreadLocation::LocalProject => "current_worktree",
790 NewThreadLocation::NewWorktree => "new_worktree",
791 };
792
793 let load_task = cx.spawn_in(window, async move |this, cx| {
794 let (connection, history) = match connect_result.await {
795 Ok(AgentConnectedState {
796 connection,
797 history,
798 }) => (connection, history),
799 Err(err) => {
800 this.update_in(cx, |this, window, cx| {
801 this.handle_load_error(err, window, cx);
802 cx.notify();
803 })
804 .log_err();
805 return;
806 }
807 };
808
809 telemetry::event!(
810 "Agent Thread Started",
811 agent = connection.telemetry_id(),
812 source = source,
813 side = side,
814 thread_location = thread_location
815 );
816
817 let mut resumed_without_history = false;
818 let result = if let Some(session_id) = resume_session_id.clone() {
819 cx.update(|_, cx| {
820 if connection.supports_load_session() {
821 connection.clone().load_session(
822 session_id,
823 project.clone(),
824 session_work_dirs,
825 title,
826 cx,
827 )
828 } else if connection.supports_resume_session() {
829 resumed_without_history = true;
830 connection.clone().resume_session(
831 session_id,
832 project.clone(),
833 session_work_dirs,
834 title,
835 cx,
836 )
837 } else {
838 Task::ready(Err(anyhow!(LoadError::Other(
839 "Loading or resuming sessions is not supported by this agent.".into()
840 ))))
841 }
842 })
843 .log_err()
844 } else {
845 cx.update(|_, cx| {
846 connection
847 .clone()
848 .new_session(project.clone(), session_work_dirs, cx)
849 })
850 .log_err()
851 };
852
853 let Some(result) = result else {
854 return;
855 };
856
857 let result = match result.await {
858 Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
859 Ok(err) => {
860 cx.update(|window, cx| {
861 Self::handle_auth_required(
862 this,
863 err,
864 agent.agent_id(),
865 connection,
866 window,
867 cx,
868 )
869 })
870 .log_err();
871 return;
872 }
873 Err(err) => Err(err),
874 },
875 Ok(thread) => Ok(thread),
876 };
877
878 this.update_in(cx, |this, window, cx| {
879 match result {
880 Ok(thread) => {
881 let root_session_id = thread.read(cx).session_id().clone();
882
883 let conversation = cx.new(|cx| {
884 let mut conversation = Conversation::default();
885 conversation.register_thread(thread.clone(), cx);
886 conversation
887 });
888
889 let current = this.new_thread_view(
890 thread,
891 conversation.clone(),
892 resumed_without_history,
893 initial_content,
894 history.clone(),
895 window,
896 cx,
897 );
898
899 if this.focus_handle.contains_focused(window, cx) {
900 current
901 .read(cx)
902 .message_editor
903 .focus_handle(cx)
904 .focus(window, cx);
905 }
906
907 this.root_session_id = Some(root_session_id.clone());
908 this.set_server_state(
909 ServerState::Connected(ConnectedServerState {
910 connection,
911 auth_state: AuthState::Ok,
912 active_id: Some(root_session_id.clone()),
913 threads: HashMap::from_iter([(root_session_id, current)]),
914 conversation,
915 history,
916 _connection_entry_subscription: connection_entry_subscription,
917 }),
918 cx,
919 );
920 }
921 Err(err) => {
922 this.handle_load_error(
923 LoadError::Other(err.to_string().into()),
924 window,
925 cx,
926 );
927 }
928 };
929 })
930 .log_err();
931 });
932
933 let loading_view = cx.new(|_cx| LoadingView {
934 _load_task: load_task,
935 });
936
937 ServerState::Loading {
938 _loading: loading_view,
939 }
940 }
941
942 fn new_thread_view(
943 &self,
944 thread: Entity<AcpThread>,
945 conversation: Entity<Conversation>,
946 resumed_without_history: bool,
947 initial_content: Option<AgentInitialContent>,
948 history: Option<Entity<ThreadHistory>>,
949 window: &mut Window,
950 cx: &mut Context<Self>,
951 ) -> Entity<ThreadView> {
952 let agent_id = self.agent.agent_id();
953 let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
954 thread.read(cx).prompt_capabilities(),
955 thread.read(cx).available_commands().to_vec(),
956 )));
957
958 let action_log = thread.read(cx).action_log().clone();
959
960 let entry_view_state = cx.new(|_| {
961 EntryViewState::new(
962 self.workspace.clone(),
963 self.project.downgrade(),
964 self.thread_store.clone(),
965 history.as_ref().map(|h| h.downgrade()),
966 self.prompt_store.clone(),
967 session_capabilities.clone(),
968 self.agent.agent_id(),
969 )
970 });
971
972 let count = thread.read(cx).entries().len();
973 let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
974 list_state.set_follow_mode(gpui::FollowMode::Tail);
975
976 entry_view_state.update(cx, |view_state, cx| {
977 for ix in 0..count {
978 view_state.sync_entry(ix, &thread, window, cx);
979 }
980 list_state.splice_focusable(
981 0..0,
982 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
983 );
984 });
985
986 if let Some(scroll_position) = thread.read(cx).ui_scroll_position() {
987 list_state.scroll_to(scroll_position);
988 } else {
989 list_state.scroll_to_end();
990 }
991
992 AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
993
994 let connection = thread.read(cx).connection().clone();
995 let session_id = thread.read(cx).session_id().clone();
996
997 // Check for config options first
998 // Config options take precedence over legacy mode/model selectors
999 // (feature flag gating happens at the data layer)
1000 let config_options_provider = connection.session_config_options(&session_id, cx);
1001
1002 let config_options_view;
1003 let mode_selector;
1004 let model_selector;
1005 if let Some(config_options) = config_options_provider {
1006 // Use config options - don't create mode_selector or model_selector
1007 let agent_server = self.agent.clone();
1008 let fs = self.project.read(cx).fs().clone();
1009 config_options_view =
1010 Some(cx.new(|cx| {
1011 ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
1012 }));
1013 model_selector = None;
1014 mode_selector = None;
1015 } else {
1016 // Fall back to legacy mode/model selectors
1017 config_options_view = None;
1018 model_selector = connection.model_selector(&session_id).map(|selector| {
1019 let agent_server = self.agent.clone();
1020 let fs = self.project.read(cx).fs().clone();
1021 cx.new(|cx| {
1022 ModelSelectorPopover::new(
1023 selector,
1024 agent_server,
1025 fs,
1026 PopoverMenuHandle::default(),
1027 self.focus_handle(cx),
1028 window,
1029 cx,
1030 )
1031 })
1032 });
1033
1034 mode_selector = connection
1035 .session_modes(&session_id, cx)
1036 .map(|session_modes| {
1037 let fs = self.project.read(cx).fs().clone();
1038 cx.new(|_cx| ModeSelector::new(session_modes, self.agent.clone(), fs))
1039 });
1040 }
1041
1042 let subscriptions = vec![
1043 cx.subscribe_in(&thread, window, Self::handle_thread_event),
1044 cx.observe(&action_log, |_, _, cx| cx.notify()),
1045 ];
1046
1047 let subagent_sessions = thread
1048 .read(cx)
1049 .entries()
1050 .iter()
1051 .filter_map(|entry| match entry {
1052 AgentThreadEntry::ToolCall(call) => call
1053 .subagent_session_info
1054 .as_ref()
1055 .map(|i| i.session_id.clone()),
1056 _ => None,
1057 })
1058 .collect::<Vec<_>>();
1059
1060 if !subagent_sessions.is_empty() {
1061 let parent_session_id = thread.read(cx).session_id().clone();
1062 cx.spawn_in(window, async move |this, cx| {
1063 this.update_in(cx, |this, window, cx| {
1064 for subagent_id in subagent_sessions {
1065 this.load_subagent_session(
1066 subagent_id,
1067 parent_session_id.clone(),
1068 window,
1069 cx,
1070 );
1071 }
1072 })
1073 })
1074 .detach();
1075 }
1076
1077 let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
1078 connection.clone().downcast();
1079 let profile_selector = profile_selector
1080 .and_then(|native_connection| native_connection.thread(&session_id, cx))
1081 .map(|native_thread| {
1082 cx.new(|cx| {
1083 ProfileSelector::new(
1084 <dyn Fs>::global(cx),
1085 Arc::new(native_thread),
1086 self.focus_handle(cx),
1087 cx,
1088 )
1089 })
1090 });
1091
1092 let agent_display_name = self
1093 .agent_server_store
1094 .read(cx)
1095 .agent_display_name(&agent_id.clone())
1096 .unwrap_or_else(|| agent_id.0.clone());
1097
1098 let agent_icon = self.agent.logo();
1099 let agent_icon_from_external_svg = self
1100 .agent_server_store
1101 .read(cx)
1102 .agent_icon(&self.agent.agent_id())
1103 .or_else(|| {
1104 project::AgentRegistryStore::try_global(cx).and_then(|store| {
1105 store
1106 .read(cx)
1107 .agent(&self.agent.agent_id())
1108 .and_then(|a| a.icon_path().cloned())
1109 })
1110 });
1111
1112 let weak = cx.weak_entity();
1113 cx.new(|cx| {
1114 ThreadView::new(
1115 thread,
1116 conversation,
1117 weak,
1118 agent_icon,
1119 agent_icon_from_external_svg,
1120 agent_id,
1121 agent_display_name,
1122 self.workspace.clone(),
1123 entry_view_state,
1124 config_options_view,
1125 mode_selector,
1126 model_selector,
1127 profile_selector,
1128 list_state,
1129 session_capabilities,
1130 resumed_without_history,
1131 self.project.downgrade(),
1132 self.thread_store.clone(),
1133 history,
1134 self.prompt_store.clone(),
1135 initial_content,
1136 subscriptions,
1137 window,
1138 cx,
1139 )
1140 })
1141 }
1142
1143 fn handle_auth_required(
1144 this: WeakEntity<Self>,
1145 err: AuthRequired,
1146 agent_id: AgentId,
1147 connection: Rc<dyn AgentConnection>,
1148 window: &mut Window,
1149 cx: &mut App,
1150 ) {
1151 let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
1152 let registry = LanguageModelRegistry::global(cx);
1153
1154 let sub = window.subscribe(®istry, cx, {
1155 let provider_id = provider_id.clone();
1156 let this = this.clone();
1157 move |_, ev, window, cx| {
1158 if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
1159 && &provider_id == updated_provider_id
1160 && LanguageModelRegistry::global(cx)
1161 .read(cx)
1162 .provider(&provider_id)
1163 .map_or(false, |provider| provider.is_authenticated(cx))
1164 {
1165 this.update(cx, |this, cx| {
1166 this.reset(window, cx);
1167 })
1168 .ok();
1169 }
1170 }
1171 });
1172
1173 let view = registry.read(cx).provider(&provider_id).map(|provider| {
1174 provider.configuration_view(
1175 language_model::ConfigurationViewTargetAgent::Other(agent_id.0),
1176 window,
1177 cx,
1178 )
1179 });
1180
1181 (view, Some(sub))
1182 } else {
1183 (None, None)
1184 };
1185
1186 this.update(cx, |this, cx| {
1187 let description = err
1188 .description
1189 .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
1190 let auth_state = AuthState::Unauthenticated {
1191 pending_auth_method: None,
1192 configuration_view,
1193 description,
1194 _subscription: subscription,
1195 };
1196 if let Some(connected) = this.as_connected_mut() {
1197 connected.auth_state = auth_state;
1198 if let Some(view) = connected.active_view()
1199 && view
1200 .read(cx)
1201 .message_editor
1202 .focus_handle(cx)
1203 .is_focused(window)
1204 {
1205 this.focus_handle.focus(window, cx)
1206 }
1207 } else {
1208 this.set_server_state(
1209 ServerState::Connected(ConnectedServerState {
1210 auth_state,
1211 active_id: None,
1212 threads: HashMap::default(),
1213 connection,
1214 conversation: cx.new(|_cx| Conversation::default()),
1215 history: None,
1216 _connection_entry_subscription: Subscription::new(|| {}),
1217 }),
1218 cx,
1219 );
1220 }
1221 cx.notify();
1222 })
1223 .ok();
1224 }
1225
1226 fn handle_load_error(&mut self, err: LoadError, window: &mut Window, cx: &mut Context<Self>) {
1227 if let Some(view) = self.root_thread_view() {
1228 if view
1229 .read(cx)
1230 .message_editor
1231 .focus_handle(cx)
1232 .is_focused(window)
1233 {
1234 self.focus_handle.focus(window, cx)
1235 }
1236 }
1237 self.emit_load_error_telemetry(&err);
1238 self.set_server_state(ServerState::LoadError { error: err }, cx);
1239 }
1240
1241 fn handle_agent_servers_updated(
1242 &mut self,
1243 _agent_server_store: &Entity<project::AgentServerStore>,
1244 _event: &project::AgentServersUpdated,
1245 window: &mut Window,
1246 cx: &mut Context<Self>,
1247 ) {
1248 // If we're in a LoadError state OR have a thread_error set (which can happen
1249 // when agent.connect() fails during loading), retry loading the thread.
1250 // This handles the case where a thread is restored before authentication completes.
1251 let should_retry = match &self.server_state {
1252 ServerState::Loading { .. } => false,
1253 ServerState::LoadError { .. } => true,
1254 ServerState::Connected(connected) => {
1255 connected.auth_state.is_ok() && connected.has_thread_error(cx)
1256 }
1257 };
1258
1259 if should_retry {
1260 if let Some(active) = self.root_thread_view() {
1261 active.update(cx, |active, cx| {
1262 active.clear_thread_error(cx);
1263 });
1264 }
1265 self.reset(window, cx);
1266 }
1267 }
1268
1269 pub fn workspace(&self) -> &WeakEntity<Workspace> {
1270 &self.workspace
1271 }
1272
1273 pub fn agent_key(&self) -> &Agent {
1274 &self.connection_key
1275 }
1276
1277 pub fn title(&self, cx: &App) -> SharedString {
1278 match &self.server_state {
1279 ServerState::Connected(view) => view
1280 .active_view()
1281 .and_then(|v| v.read(cx).thread.read(cx).title())
1282 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()),
1283 ServerState::Loading { .. } => "Loading…".into(),
1284 ServerState::LoadError { error, .. } => match error {
1285 LoadError::Unsupported { .. } => {
1286 format!("Upgrade {}", self.agent.agent_id()).into()
1287 }
1288 LoadError::FailedToInstall(_) => {
1289 format!("Failed to Install {}", self.agent.agent_id()).into()
1290 }
1291 LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(),
1292 LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(),
1293 },
1294 }
1295 }
1296
1297 pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1298 if let Some(active) = self.active_thread() {
1299 active.update(cx, |active, cx| {
1300 active.cancel_generation(cx);
1301 });
1302 }
1303 }
1304
1305 pub fn parent_id(&self) -> ThreadId {
1306 self.thread_id
1307 }
1308
1309 pub fn is_loading(&self) -> bool {
1310 matches!(self.server_state, ServerState::Loading { .. })
1311 }
1312
1313 fn send_queued_message_at_index(
1314 &mut self,
1315 index: usize,
1316 is_send_now: bool,
1317 window: &mut Window,
1318 cx: &mut Context<Self>,
1319 ) {
1320 if let Some(active) = self.root_thread_view() {
1321 active.update(cx, |active, cx| {
1322 active.send_queued_message_at_index(index, is_send_now, window, cx);
1323 });
1324 }
1325 }
1326
1327 fn move_queued_message_to_main_editor(
1328 &mut self,
1329 index: usize,
1330 inserted_text: Option<&str>,
1331 cursor_offset: Option<usize>,
1332 window: &mut Window,
1333 cx: &mut Context<Self>,
1334 ) {
1335 if let Some(active) = self.root_thread_view() {
1336 active.update(cx, |active, cx| {
1337 active.move_queued_message_to_main_editor(
1338 index,
1339 inserted_text,
1340 cursor_offset,
1341 window,
1342 cx,
1343 );
1344 });
1345 }
1346 }
1347
1348 fn handle_thread_event(
1349 &mut self,
1350 thread: &Entity<AcpThread>,
1351 event: &AcpThreadEvent,
1352 window: &mut Window,
1353 cx: &mut Context<Self>,
1354 ) {
1355 let session_id = thread.read(cx).session_id().clone();
1356 let has_thread = self
1357 .as_connected()
1358 .is_some_and(|connected| connected.threads.contains_key(&session_id));
1359 if !has_thread {
1360 return;
1361 };
1362 let is_subagent = thread.read(cx).parent_session_id().is_some();
1363 if !is_subagent && affects_thread_metadata(event) {
1364 cx.emit(RootThreadUpdated);
1365 }
1366 match event {
1367 AcpThreadEvent::NewEntry => {
1368 let len = thread.read(cx).entries().len();
1369 let index = len - 1;
1370 if let Some(active) = self.thread_view(&session_id) {
1371 let entry_view_state = active.read(cx).entry_view_state.clone();
1372 let list_state = active.read(cx).list_state.clone();
1373 entry_view_state.update(cx, |view_state, cx| {
1374 view_state.sync_entry(index, thread, window, cx);
1375 list_state.splice_focusable(
1376 index..index,
1377 [view_state
1378 .entry(index)
1379 .and_then(|entry| entry.focus_handle(cx))],
1380 );
1381 });
1382 active.update(cx, |active, cx| {
1383 active.sync_editor_mode_for_empty_state(cx);
1384 });
1385 }
1386 }
1387 AcpThreadEvent::EntryUpdated(index) => {
1388 if let Some(active) = self.thread_view(&session_id) {
1389 let entry_view_state = active.read(cx).entry_view_state.clone();
1390 let list_state = active.read(cx).list_state.clone();
1391 entry_view_state.update(cx, |view_state, cx| {
1392 view_state.sync_entry(*index, thread, window, cx);
1393 });
1394 list_state.remeasure_items(*index..*index + 1);
1395 active.update(cx, |active, cx| {
1396 active.auto_expand_streaming_thought(cx);
1397 });
1398 }
1399 }
1400 AcpThreadEvent::EntriesRemoved(range) => {
1401 if let Some(active) = self.thread_view(&session_id) {
1402 let entry_view_state = active.read(cx).entry_view_state.clone();
1403 let list_state = active.read(cx).list_state.clone();
1404 entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
1405 list_state.splice(range.clone(), 0);
1406 active.update(cx, |active, cx| {
1407 active.sync_editor_mode_for_empty_state(cx);
1408 });
1409 }
1410 }
1411 AcpThreadEvent::SubagentSpawned(subagent_session_id) => {
1412 self.load_subagent_session(subagent_session_id.clone(), session_id, window, cx)
1413 }
1414 AcpThreadEvent::ToolAuthorizationRequested(_) => {
1415 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1416 }
1417 AcpThreadEvent::ToolAuthorizationReceived(_) => {}
1418 AcpThreadEvent::Retry(retry) => {
1419 if let Some(active) = self.thread_view(&session_id) {
1420 active.update(cx, |active, _cx| {
1421 active.thread_retry_status = Some(retry.clone());
1422 });
1423 }
1424 }
1425 AcpThreadEvent::Stopped(stop_reason) => {
1426 if let Some(active) = self.thread_view(&session_id) {
1427 let is_generating =
1428 matches!(thread.read(cx).status(), ThreadStatus::Generating);
1429 active.update(cx, |active, cx| {
1430 if !is_generating {
1431 active.thread_retry_status.take();
1432 active.clear_auto_expand_tracking();
1433 if active.list_state.is_following_tail() {
1434 active.list_state.scroll_to_end();
1435 }
1436 }
1437 active.sync_generating_indicator(cx);
1438 });
1439 }
1440 if is_subagent {
1441 if *stop_reason == acp::StopReason::EndTurn {
1442 thread.update(cx, |thread, cx| {
1443 thread.mark_as_subagent_output(cx);
1444 });
1445 }
1446 return;
1447 }
1448
1449 let used_tools = thread.read(cx).used_tools_since_last_user_message();
1450 self.notify_with_sound(
1451 if used_tools {
1452 "Finished running tools"
1453 } else {
1454 "New message"
1455 },
1456 IconName::ZedAssistant,
1457 window,
1458 cx,
1459 );
1460
1461 let should_send_queued = if let Some(active) = self.root_thread_view() {
1462 active.update(cx, |active, cx| {
1463 if active.skip_queue_processing_count > 0 {
1464 active.skip_queue_processing_count -= 1;
1465 false
1466 } else if active.user_interrupted_generation {
1467 // Manual interruption: don't auto-process queue.
1468 // Reset the flag so future completions can process normally.
1469 active.user_interrupted_generation = false;
1470 false
1471 } else {
1472 let has_queued = !active.local_queued_messages.is_empty();
1473 // Don't auto-send if the first message editor is currently focused
1474 let is_first_editor_focused = active
1475 .queued_message_editors
1476 .first()
1477 .is_some_and(|editor| editor.focus_handle(cx).is_focused(window));
1478 has_queued && !is_first_editor_focused
1479 }
1480 })
1481 } else {
1482 false
1483 };
1484 if should_send_queued {
1485 self.send_queued_message_at_index(0, false, window, cx);
1486 }
1487 }
1488 AcpThreadEvent::Refusal => {
1489 let error = ThreadError::Refusal;
1490 if let Some(active) = self.thread_view(&session_id) {
1491 active.update(cx, |active, cx| {
1492 active.handle_thread_error(error, cx);
1493 active.thread_retry_status.take();
1494 });
1495 }
1496 if !is_subagent {
1497 let model_or_agent_name = self.current_model_name(cx);
1498 let notification_message =
1499 format!("{} refused to respond to this request", model_or_agent_name);
1500 self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
1501 }
1502 }
1503 AcpThreadEvent::Error => {
1504 if let Some(active) = self.thread_view(&session_id) {
1505 let is_generating =
1506 matches!(thread.read(cx).status(), ThreadStatus::Generating);
1507 active.update(cx, |active, cx| {
1508 if !is_generating {
1509 active.thread_retry_status.take();
1510 if active.list_state.is_following_tail() {
1511 active.list_state.scroll_to_end();
1512 }
1513 }
1514 active.sync_generating_indicator(cx);
1515 });
1516 }
1517 if !is_subagent {
1518 self.notify_with_sound(
1519 "Agent stopped due to an error",
1520 IconName::Warning,
1521 window,
1522 cx,
1523 );
1524 }
1525 }
1526 AcpThreadEvent::LoadError(error) => {
1527 if let Some(view) = self.root_thread_view() {
1528 if view
1529 .read(cx)
1530 .message_editor
1531 .focus_handle(cx)
1532 .is_focused(window)
1533 {
1534 self.focus_handle.focus(window, cx)
1535 }
1536 }
1537 self.set_server_state(
1538 ServerState::LoadError {
1539 error: error.clone(),
1540 },
1541 cx,
1542 );
1543 }
1544 AcpThreadEvent::TitleUpdated => {
1545 if let Some(title) = thread.read(cx).title()
1546 && let Some(active_thread) = self.thread_view(&session_id)
1547 {
1548 let title_editor = active_thread.read(cx).title_editor.clone();
1549 title_editor.update(cx, |editor, cx| {
1550 if editor.text(cx) != title {
1551 editor.set_text(title, window, cx);
1552 }
1553 });
1554 }
1555 cx.notify();
1556 }
1557 AcpThreadEvent::PromptCapabilitiesUpdated => {
1558 if let Some(active) = self.thread_view(&session_id) {
1559 active.update(cx, |active, _cx| {
1560 active
1561 .session_capabilities
1562 .write()
1563 .set_prompt_capabilities(thread.read(_cx).prompt_capabilities());
1564 });
1565 }
1566 }
1567 AcpThreadEvent::TokenUsageUpdated => {
1568 if let Some(active) = self.thread_view(&session_id) {
1569 active.update(cx, |active, cx| {
1570 active.update_turn_tokens(cx);
1571 });
1572 }
1573 }
1574 AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1575 if let Some(thread_view) = self.thread_view(&session_id) {
1576 let has_commands = !available_commands.is_empty();
1577
1578 let agent_display_name = self
1579 .agent_server_store
1580 .read(cx)
1581 .agent_display_name(&self.agent.agent_id())
1582 .unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
1583
1584 let new_placeholder =
1585 placeholder_text(agent_display_name.as_ref(), has_commands);
1586
1587 thread_view.update(cx, |thread_view, cx| {
1588 thread_view
1589 .session_capabilities
1590 .write()
1591 .set_available_commands(available_commands.clone());
1592 thread_view.message_editor.update(cx, |editor, cx| {
1593 editor.set_placeholder_text(&new_placeholder, window, cx);
1594 });
1595 });
1596 }
1597 }
1598 AcpThreadEvent::ModeUpdated(_mode) => {
1599 // The connection keeps track of the mode
1600 cx.notify();
1601 }
1602 AcpThreadEvent::ConfigOptionsUpdated(_) => {
1603 // The watch task in ConfigOptionsView handles rebuilding selectors
1604 cx.notify();
1605 }
1606 AcpThreadEvent::WorkingDirectoriesUpdated => {
1607 cx.notify();
1608 }
1609 AcpThreadEvent::PromptUpdated => {
1610 cx.notify();
1611 }
1612 }
1613 cx.notify();
1614 }
1615
1616 fn authenticate(
1617 &mut self,
1618 method: acp::AuthMethodId,
1619 window: &mut Window,
1620 cx: &mut Context<Self>,
1621 ) {
1622 let Some(workspace) = self.workspace.upgrade() else {
1623 return;
1624 };
1625 let Some(connected) = self.as_connected_mut() else {
1626 return;
1627 };
1628 let connection = connected.connection.clone();
1629
1630 let AuthState::Unauthenticated {
1631 configuration_view,
1632 pending_auth_method,
1633 ..
1634 } = &mut connected.auth_state
1635 else {
1636 return;
1637 };
1638
1639 let agent_telemetry_id = connection.telemetry_id();
1640
1641 if let Some(login_task) = connection.terminal_auth_task(&method, cx) {
1642 configuration_view.take();
1643 pending_auth_method.replace(method.clone());
1644
1645 let project = self.project.clone();
1646 cx.notify();
1647 self.auth_task = Some(cx.spawn_in(window, {
1648 async move |this, cx| {
1649 let result = async {
1650 let login = login_task.await?;
1651 this.update_in(cx, |_this, window, cx| {
1652 Self::spawn_external_agent_login(
1653 login,
1654 workspace,
1655 project,
1656 method.clone(),
1657 false,
1658 window,
1659 cx,
1660 )
1661 })?
1662 .await
1663 }
1664 .await;
1665
1666 match &result {
1667 Ok(_) => telemetry::event!(
1668 "Authenticate Agent Succeeded",
1669 agent = agent_telemetry_id
1670 ),
1671 Err(_) => {
1672 telemetry::event!(
1673 "Authenticate Agent Failed",
1674 agent = agent_telemetry_id,
1675 )
1676 }
1677 }
1678
1679 this.update_in(cx, |this, window, cx| {
1680 if let Err(err) = result {
1681 if let Some(ConnectedServerState {
1682 auth_state:
1683 AuthState::Unauthenticated {
1684 pending_auth_method,
1685 ..
1686 },
1687 ..
1688 }) = this.as_connected_mut()
1689 {
1690 pending_auth_method.take();
1691 }
1692 if let Some(active) = this.root_thread_view() {
1693 active.update(cx, |active, cx| {
1694 active.handle_thread_error(err, cx);
1695 })
1696 }
1697 } else {
1698 this.reset(window, cx);
1699 }
1700 this.auth_task.take()
1701 })
1702 .ok();
1703 }
1704 }));
1705 return;
1706 }
1707
1708 configuration_view.take();
1709 pending_auth_method.replace(method.clone());
1710
1711 let authenticate = connection.authenticate(method, cx);
1712 cx.notify();
1713 self.auth_task = Some(cx.spawn_in(window, {
1714 async move |this, cx| {
1715 let result = authenticate.await;
1716
1717 match &result {
1718 Ok(_) => telemetry::event!(
1719 "Authenticate Agent Succeeded",
1720 agent = agent_telemetry_id
1721 ),
1722 Err(_) => {
1723 telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
1724 }
1725 }
1726
1727 this.update_in(cx, |this, window, cx| {
1728 if let Err(err) = result {
1729 if let Some(ConnectedServerState {
1730 auth_state:
1731 AuthState::Unauthenticated {
1732 pending_auth_method,
1733 ..
1734 },
1735 ..
1736 }) = this.as_connected_mut()
1737 {
1738 pending_auth_method.take();
1739 }
1740 if let Some(active) = this.root_thread_view() {
1741 active.update(cx, |active, cx| active.handle_thread_error(err, cx));
1742 }
1743 } else {
1744 this.reset(window, cx);
1745 }
1746 this.auth_task.take()
1747 })
1748 .ok();
1749 }
1750 }));
1751 }
1752
1753 fn load_subagent_session(
1754 &mut self,
1755 subagent_id: acp::SessionId,
1756 parent_session_id: acp::SessionId,
1757 window: &mut Window,
1758 cx: &mut Context<Self>,
1759 ) {
1760 let Some(connected) = self.as_connected() else {
1761 return;
1762 };
1763 if connected.threads.contains_key(&subagent_id)
1764 || !connected.connection.supports_load_session()
1765 {
1766 return;
1767 }
1768 let Some(parent_thread) = connected.threads.get(&parent_session_id) else {
1769 return;
1770 };
1771 let work_dirs = parent_thread
1772 .read(cx)
1773 .thread
1774 .read(cx)
1775 .work_dirs()
1776 .cloned()
1777 .unwrap_or_else(|| self.project.read(cx).default_path_list(cx));
1778
1779 let subagent_thread_task = connected.connection.clone().load_session(
1780 subagent_id,
1781 self.project.clone(),
1782 work_dirs,
1783 None,
1784 cx,
1785 );
1786
1787 cx.spawn_in(window, async move |this, cx| {
1788 let subagent_thread = subagent_thread_task.await?;
1789 this.update_in(cx, |this, window, cx| {
1790 let Some((conversation, history)) = this
1791 .as_connected()
1792 .map(|connected| (connected.conversation.clone(), connected.history.clone()))
1793 else {
1794 return;
1795 };
1796 let subagent_session_id = subagent_thread.read(cx).session_id().clone();
1797 conversation.update(cx, |conversation, cx| {
1798 conversation.register_thread(subagent_thread.clone(), cx);
1799 });
1800 let view = this.new_thread_view(
1801 subagent_thread,
1802 conversation,
1803 false,
1804 None,
1805 history,
1806 window,
1807 cx,
1808 );
1809 let Some(connected) = this.as_connected_mut() else {
1810 return;
1811 };
1812 connected.threads.insert(subagent_session_id, view);
1813 })
1814 })
1815 .detach();
1816 }
1817
1818 fn spawn_external_agent_login(
1819 login: task::SpawnInTerminal,
1820 workspace: Entity<Workspace>,
1821 project: Entity<Project>,
1822 method: acp::AuthMethodId,
1823 previous_attempt: bool,
1824 window: &mut Window,
1825 cx: &mut App,
1826 ) -> Task<Result<()>> {
1827 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1828 return Task::ready(Err(anyhow!("Terminal panel is unavailable")));
1829 };
1830
1831 window.spawn(cx, async move |cx| {
1832 let mut task = login.clone();
1833 if let Some(cmd) = &task.command {
1834 // Have "node" command use Zed's managed Node runtime by default
1835 if cmd == "node" {
1836 let resolved_node_runtime = project.update(cx, |project, cx| {
1837 let agent_server_store = project.agent_server_store().clone();
1838 agent_server_store.update(cx, |store, cx| {
1839 store.node_runtime().map(|node_runtime| {
1840 cx.background_spawn(async move { node_runtime.binary_path().await })
1841 })
1842 })
1843 });
1844
1845 if let Some(resolve_task) = resolved_node_runtime {
1846 if let Ok(node_path) = resolve_task.await {
1847 task.command = Some(node_path.to_string_lossy().to_string());
1848 }
1849 }
1850 }
1851 }
1852 task.shell = task::Shell::WithArguments {
1853 program: task.command.take().expect("login command should be set"),
1854 args: std::mem::take(&mut task.args),
1855 title_override: None,
1856 };
1857
1858 let terminal = terminal_panel
1859 .update_in(cx, |terminal_panel, window, cx| {
1860 terminal_panel.spawn_task(&task, window, cx)
1861 })?
1862 .await?;
1863
1864 let success_patterns = match method.0.as_ref() {
1865 "claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec![
1866 "Login successful".to_string(),
1867 "Type your message".to_string(),
1868 ],
1869 _ => Vec::new(),
1870 };
1871 if success_patterns.is_empty() {
1872 // No success patterns specified: wait for the process to exit and check exit code
1873 let exit_status = terminal
1874 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1875 .await;
1876
1877 match exit_status {
1878 Some(status) if status.success() => Ok(()),
1879 Some(status) => Err(anyhow!(
1880 "Login command failed with exit code: {:?}",
1881 status.code()
1882 )),
1883 None => Err(anyhow!("Login command terminated without exit status")),
1884 }
1885 } else {
1886 // Look for specific output patterns to detect successful login
1887 let mut exit_status = terminal
1888 .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1889 .fuse();
1890
1891 let logged_in = cx
1892 .spawn({
1893 let terminal = terminal.clone();
1894 async move |cx| {
1895 loop {
1896 cx.background_executor().timer(Duration::from_secs(1)).await;
1897 let content =
1898 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
1899 if success_patterns
1900 .iter()
1901 .any(|pattern| content.contains(pattern))
1902 {
1903 return anyhow::Ok(());
1904 }
1905 }
1906 }
1907 })
1908 .fuse();
1909 futures::pin_mut!(logged_in);
1910 futures::select_biased! {
1911 result = logged_in => {
1912 if let Err(e) = result {
1913 log::error!("{e}");
1914 return Err(anyhow!("exited before logging in"));
1915 }
1916 }
1917 _ = exit_status => {
1918 if !previous_attempt
1919 && project.read_with(cx, |project, _| project.is_via_remote_server())
1920 && method.0.as_ref() == GEMINI_TERMINAL_AUTH_METHOD_ID
1921 {
1922 return cx
1923 .update(|window, cx| {
1924 Self::spawn_external_agent_login(
1925 login,
1926 workspace,
1927 project.clone(),
1928 method,
1929 true,
1930 window,
1931 cx,
1932 )
1933 })?
1934 .await;
1935 }
1936 return Err(anyhow!("exited before logging in"));
1937 }
1938 }
1939 terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
1940 Ok(())
1941 }
1942 })
1943 }
1944
1945 pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
1946 self.root_thread_view().is_some_and(|active| {
1947 active
1948 .read(cx)
1949 .thread
1950 .read(cx)
1951 .entries()
1952 .iter()
1953 .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
1954 })
1955 }
1956
1957 fn render_auth_required_state(
1958 &self,
1959 connection: &Rc<dyn AgentConnection>,
1960 description: Option<&Entity<Markdown>>,
1961 configuration_view: Option<&AnyView>,
1962 pending_auth_method: Option<&acp::AuthMethodId>,
1963 window: &mut Window,
1964 cx: &Context<Self>,
1965 ) -> impl IntoElement {
1966 let auth_methods = connection.auth_methods();
1967
1968 let agent_display_name = self
1969 .agent_server_store
1970 .read(cx)
1971 .agent_display_name(&self.agent.agent_id())
1972 .unwrap_or_else(|| self.agent.agent_id().0);
1973
1974 let show_fallback_description = auth_methods.len() > 1
1975 && configuration_view.is_none()
1976 && description.is_none()
1977 && pending_auth_method.is_none();
1978
1979 let auth_buttons = || {
1980 h_flex().justify_end().flex_wrap().gap_1().children(
1981 connection
1982 .auth_methods()
1983 .iter()
1984 .enumerate()
1985 .rev()
1986 .map(|(ix, method)| {
1987 let (method_id, name) = (method.id().0.clone(), method.name().to_string());
1988 let agent_telemetry_id = connection.telemetry_id();
1989
1990 Button::new(method_id.clone(), name)
1991 .label_size(LabelSize::Small)
1992 .map(|this| {
1993 if ix == 0 {
1994 this.style(ButtonStyle::Tinted(TintColor::Accent))
1995 } else {
1996 this.style(ButtonStyle::Outlined)
1997 }
1998 })
1999 .when_some(method.description(), |this, description| {
2000 this.tooltip(Tooltip::text(description.to_string()))
2001 })
2002 .on_click({
2003 cx.listener(move |this, _, window, cx| {
2004 telemetry::event!(
2005 "Authenticate Agent Started",
2006 agent = agent_telemetry_id,
2007 method = method_id
2008 );
2009
2010 this.authenticate(
2011 acp::AuthMethodId::new(method_id.clone()),
2012 window,
2013 cx,
2014 )
2015 })
2016 })
2017 }),
2018 )
2019 };
2020
2021 if pending_auth_method.is_some() {
2022 return Callout::new()
2023 .icon(IconName::Info)
2024 .title(format!("Authenticating to {}…", agent_display_name))
2025 .actions_slot(
2026 Icon::new(IconName::ArrowCircle)
2027 .size(IconSize::Small)
2028 .color(Color::Muted)
2029 .with_rotate_animation(2)
2030 .into_any_element(),
2031 )
2032 .into_any_element();
2033 }
2034
2035 Callout::new()
2036 .icon(IconName::Info)
2037 .title(format!("Authenticate to {}", agent_display_name))
2038 .when(auth_methods.len() == 1, |this| {
2039 this.actions_slot(auth_buttons())
2040 })
2041 .description_slot(
2042 v_flex()
2043 .text_ui(cx)
2044 .map(|this| {
2045 if show_fallback_description {
2046 this.child(
2047 Label::new("Choose one of the following authentication options:")
2048 .size(LabelSize::Small)
2049 .color(Color::Muted),
2050 )
2051 } else {
2052 this.children(
2053 configuration_view
2054 .cloned()
2055 .map(|view| div().w_full().child(view)),
2056 )
2057 .children(description.map(|desc| {
2058 self.render_markdown(
2059 desc.clone(),
2060 MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
2061 )
2062 }))
2063 }
2064 })
2065 .when(auth_methods.len() > 1, |this| {
2066 this.gap_1().child(auth_buttons())
2067 }),
2068 )
2069 .into_any_element()
2070 }
2071
2072 fn emit_load_error_telemetry(&self, error: &LoadError) {
2073 let error_kind = match error {
2074 LoadError::Unsupported { .. } => "unsupported",
2075 LoadError::FailedToInstall(_) => "failed_to_install",
2076 LoadError::Exited { .. } => "exited",
2077 LoadError::Other(_) => "other",
2078 };
2079
2080 let agent_name = self.agent.agent_id();
2081
2082 telemetry::event!(
2083 "Agent Panel Error Shown",
2084 agent = agent_name,
2085 kind = error_kind,
2086 message = error.to_string(),
2087 );
2088 }
2089
2090 fn render_load_error(
2091 &self,
2092 e: &LoadError,
2093 window: &mut Window,
2094 cx: &mut Context<Self>,
2095 ) -> AnyElement {
2096 let (title, message, action_slot): (_, SharedString, _) = match e {
2097 LoadError::Unsupported {
2098 command: path,
2099 current_version,
2100 minimum_version,
2101 } => {
2102 return self.render_unsupported(path, current_version, minimum_version, window, cx);
2103 }
2104 LoadError::FailedToInstall(msg) => (
2105 "Failed to Install",
2106 msg.into(),
2107 Some(self.create_copy_button(msg.to_string()).into_any_element()),
2108 ),
2109 LoadError::Exited { status } => (
2110 "Failed to Launch",
2111 format!("Server exited with status {status}").into(),
2112 None,
2113 ),
2114 LoadError::Other(msg) => (
2115 "Failed to Launch",
2116 msg.into(),
2117 Some(self.create_copy_button(msg.to_string()).into_any_element()),
2118 ),
2119 };
2120
2121 Callout::new()
2122 .severity(Severity::Error)
2123 .icon(IconName::XCircleFilled)
2124 .title(title)
2125 .description(message)
2126 .actions_slot(div().children(action_slot))
2127 .into_any_element()
2128 }
2129
2130 fn render_unsupported(
2131 &self,
2132 path: &SharedString,
2133 version: &SharedString,
2134 minimum_version: &SharedString,
2135 _window: &mut Window,
2136 cx: &mut Context<Self>,
2137 ) -> AnyElement {
2138 let (heading_label, description_label) = (
2139 format!("Upgrade {} to work with Zed", self.agent.agent_id()),
2140 if version.is_empty() {
2141 format!(
2142 "Currently using {}, which does not report a valid --version",
2143 path,
2144 )
2145 } else {
2146 format!(
2147 "Currently using {}, which is only version {} (need at least {minimum_version})",
2148 path, version
2149 )
2150 },
2151 );
2152
2153 v_flex()
2154 .w_full()
2155 .p_3p5()
2156 .gap_2p5()
2157 .border_t_1()
2158 .border_color(cx.theme().colors().border)
2159 .bg(linear_gradient(
2160 180.,
2161 linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
2162 linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
2163 ))
2164 .child(
2165 v_flex().gap_0p5().child(Label::new(heading_label)).child(
2166 Label::new(description_label)
2167 .size(LabelSize::Small)
2168 .color(Color::Muted),
2169 ),
2170 )
2171 .into_any_element()
2172 }
2173
2174 pub(crate) fn as_native_connection(
2175 &self,
2176 cx: &App,
2177 ) -> Option<Rc<agent::NativeAgentConnection>> {
2178 self.root_thread(cx)?
2179 .read(cx)
2180 .connection()
2181 .clone()
2182 .downcast()
2183 }
2184
2185 pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2186 self.as_native_connection(cx)?
2187 .thread(self.root_session_id.as_ref()?, cx)
2188 }
2189
2190 fn queued_messages_len(&self, cx: &App) -> usize {
2191 self.root_thread_view()
2192 .map(|thread| thread.read(cx).local_queued_messages.len())
2193 .unwrap_or_default()
2194 }
2195
2196 fn update_queued_message(
2197 &mut self,
2198 index: usize,
2199 content: Vec<acp::ContentBlock>,
2200 tracked_buffers: Vec<Entity<Buffer>>,
2201 cx: &mut Context<Self>,
2202 ) -> bool {
2203 match self.root_thread_view() {
2204 Some(thread) => thread.update(cx, |thread, _cx| {
2205 if index < thread.local_queued_messages.len() {
2206 thread.local_queued_messages[index] = QueuedMessage {
2207 content,
2208 tracked_buffers,
2209 };
2210 true
2211 } else {
2212 false
2213 }
2214 }),
2215 None => false,
2216 }
2217 }
2218
2219 fn queued_message_contents(&self, cx: &App) -> Vec<Vec<acp::ContentBlock>> {
2220 match self.root_thread_view() {
2221 None => Vec::new(),
2222 Some(thread) => thread
2223 .read(cx)
2224 .local_queued_messages
2225 .iter()
2226 .map(|q| q.content.clone())
2227 .collect(),
2228 }
2229 }
2230
2231 fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context<Self>) {
2232 let editor = match self.root_thread_view() {
2233 Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(),
2234 None => None,
2235 };
2236 let Some(editor) = editor else {
2237 return;
2238 };
2239
2240 let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx));
2241
2242 cx.spawn(async move |this, cx| {
2243 let Ok((content, tracked_buffers)) = contents_task.await else {
2244 return Ok::<(), anyhow::Error>(());
2245 };
2246
2247 this.update(cx, |this, cx| {
2248 this.update_queued_message(index, content, tracked_buffers, cx);
2249 cx.notify();
2250 })?;
2251
2252 Ok(())
2253 })
2254 .detach_and_log_err(cx);
2255 }
2256
2257 fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2258 let needed_count = self.queued_messages_len(cx);
2259 let queued_messages = self.queued_message_contents(cx);
2260
2261 let agent_name = self.agent.agent_id();
2262 let workspace = self.workspace.clone();
2263 let project = self.project.downgrade();
2264 let Some(connected) = self.as_connected() else {
2265 return;
2266 };
2267 let history = connected.history.as_ref().map(|h| h.downgrade());
2268 let Some(thread) = connected.active_view() else {
2269 return;
2270 };
2271 let session_capabilities = thread.read(cx).session_capabilities.clone();
2272
2273 let current_count = thread.read(cx).queued_message_editors.len();
2274 let last_synced = thread.read(cx).last_synced_queue_length;
2275
2276 if current_count == needed_count && needed_count == last_synced {
2277 return;
2278 }
2279
2280 if current_count > needed_count {
2281 thread.update(cx, |thread, _cx| {
2282 thread.queued_message_editors.truncate(needed_count);
2283 thread
2284 .queued_message_editor_subscriptions
2285 .truncate(needed_count);
2286 });
2287
2288 let editors = thread.read(cx).queued_message_editors.clone();
2289 for (index, editor) in editors.into_iter().enumerate() {
2290 if let Some(content) = queued_messages.get(index) {
2291 editor.update(cx, |editor, cx| {
2292 editor.set_read_only(true, cx);
2293 editor.set_message(content.clone(), window, cx);
2294 });
2295 }
2296 }
2297 }
2298
2299 while thread.read(cx).queued_message_editors.len() < needed_count {
2300 let index = thread.read(cx).queued_message_editors.len();
2301 let content = queued_messages.get(index).cloned().unwrap_or_default();
2302
2303 let editor = cx.new(|cx| {
2304 let mut editor = MessageEditor::new(
2305 workspace.clone(),
2306 project.clone(),
2307 None,
2308 history.clone(),
2309 None,
2310 session_capabilities.clone(),
2311 agent_name.clone(),
2312 "",
2313 EditorMode::AutoHeight {
2314 min_lines: 1,
2315 max_lines: Some(10),
2316 },
2317 window,
2318 cx,
2319 );
2320 editor.set_read_only(true, cx);
2321 editor.set_message(content, window, cx);
2322 editor
2323 });
2324
2325 let subscription = cx.subscribe_in(
2326 &editor,
2327 window,
2328 move |this, _editor, event, window, cx| match event {
2329 MessageEditorEvent::InputAttempted {
2330 text,
2331 cursor_offset,
2332 } => this.move_queued_message_to_main_editor(
2333 index,
2334 Some(text.as_ref()),
2335 Some(*cursor_offset),
2336 window,
2337 cx,
2338 ),
2339 MessageEditorEvent::LostFocus => {
2340 this.save_queued_message_at_index(index, cx);
2341 }
2342 MessageEditorEvent::Cancel => {
2343 window.focus(&this.focus_handle(cx), cx);
2344 }
2345 MessageEditorEvent::Send => {
2346 window.focus(&this.focus_handle(cx), cx);
2347 }
2348 MessageEditorEvent::SendImmediately => {
2349 this.send_queued_message_at_index(index, true, window, cx);
2350 }
2351 _ => {}
2352 },
2353 );
2354
2355 thread.update(cx, |thread, _cx| {
2356 thread.queued_message_editors.push(editor);
2357 thread
2358 .queued_message_editor_subscriptions
2359 .push(subscription);
2360 });
2361 }
2362
2363 if let Some(active) = self.root_thread_view() {
2364 active.update(cx, |active, _cx| {
2365 active.last_synced_queue_length = needed_count;
2366 });
2367 }
2368 }
2369
2370 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2371 let workspace = self.workspace.clone();
2372 MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2373 crate::conversation_view::thread_view::open_link(text, &workspace, window, cx);
2374 })
2375 }
2376
2377 fn notify_with_sound(
2378 &mut self,
2379 caption: impl Into<SharedString>,
2380 icon: IconName,
2381 window: &mut Window,
2382 cx: &mut Context<Self>,
2383 ) {
2384 #[cfg(feature = "audio")]
2385 self.play_notification_sound(window, cx);
2386 self.show_notification(caption, icon, window, cx);
2387 }
2388
2389 fn is_visible(&self, multi_workspace: &Entity<MultiWorkspace>, cx: &Context<Self>) -> bool {
2390 let Some(workspace) = self.workspace.upgrade() else {
2391 return false;
2392 };
2393
2394 multi_workspace.read(cx).sidebar_open()
2395 || multi_workspace.read(cx).workspace() == &workspace
2396 && AgentPanel::is_visible(&workspace, cx)
2397 && multi_workspace
2398 .read(cx)
2399 .workspace()
2400 .read(cx)
2401 .panel::<AgentPanel>(cx)
2402 .map_or(false, |p| {
2403 p.read(cx).active_conversation_view().map(|c| c.entity_id())
2404 == Some(cx.entity_id())
2405 })
2406 }
2407
2408 fn agent_status_visible(&self, window: &Window, cx: &Context<Self>) -> bool {
2409 if !window.is_window_active() {
2410 return false;
2411 }
2412
2413 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2414 self.is_visible(&multi_workspace, cx)
2415 } else {
2416 self.workspace
2417 .upgrade()
2418 .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2419 }
2420 }
2421
2422 #[cfg(feature = "audio")]
2423 fn play_notification_sound(&self, window: &Window, cx: &mut Context<Self>) {
2424 let visible = window.is_window_active()
2425 && if let Some(mw) = window.root::<MultiWorkspace>().flatten() {
2426 self.is_visible(&mw, cx)
2427 } else {
2428 self.workspace
2429 .upgrade()
2430 .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2431 };
2432 let settings = AgentSettings::get_global(cx);
2433 if settings.play_sound_when_agent_done.should_play(visible) {
2434 Audio::play_sound(Sound::AgentDone, cx);
2435 }
2436 }
2437
2438 fn show_notification(
2439 &mut self,
2440 caption: impl Into<SharedString>,
2441 icon: IconName,
2442 window: &mut Window,
2443 cx: &mut Context<Self>,
2444 ) {
2445 if !self.notifications.is_empty() {
2446 return;
2447 }
2448
2449 let settings = AgentSettings::get_global(cx);
2450
2451 let should_notify = !self.agent_status_visible(window, cx);
2452
2453 if !should_notify {
2454 return;
2455 }
2456
2457 let Some(root_thread) = self.root_thread_view() else {
2458 return;
2459 };
2460 let root_thread = root_thread.read(cx).thread.read(cx);
2461 let root_session_id = root_thread.session_id().clone();
2462 let root_work_dirs = root_thread.work_dirs().cloned();
2463 let root_title = root_thread.title();
2464
2465 // TODO: Change this once we have title summarization for external agents.
2466 let title = self.agent.agent_id().0;
2467
2468 match settings.notify_when_agent_waiting {
2469 NotifyWhenAgentWaiting::PrimaryScreen => {
2470 if let Some(primary) = cx.primary_display() {
2471 self.pop_up(
2472 icon,
2473 caption.into(),
2474 title,
2475 root_session_id,
2476 root_work_dirs,
2477 root_title,
2478 window,
2479 primary,
2480 cx,
2481 );
2482 }
2483 }
2484 NotifyWhenAgentWaiting::AllScreens => {
2485 let caption = caption.into();
2486 for screen in cx.displays() {
2487 self.pop_up(
2488 icon,
2489 caption.clone(),
2490 title.clone(),
2491 root_session_id.clone(),
2492 root_work_dirs.clone(),
2493 root_title.clone(),
2494 window,
2495 screen,
2496 cx,
2497 );
2498 }
2499 }
2500 NotifyWhenAgentWaiting::Never => {
2501 // Don't show anything
2502 }
2503 }
2504 }
2505
2506 fn pop_up(
2507 &mut self,
2508 icon: IconName,
2509 caption: SharedString,
2510 title: SharedString,
2511 root_session_id: acp::SessionId,
2512 root_work_dirs: Option<PathList>,
2513 root_title: Option<SharedString>,
2514 window: &mut Window,
2515 screen: Rc<dyn PlatformDisplay>,
2516 cx: &mut Context<Self>,
2517 ) {
2518 let options = AgentNotification::window_options(screen, cx);
2519
2520 let project_name = self.workspace.upgrade().and_then(|workspace| {
2521 workspace
2522 .read(cx)
2523 .project()
2524 .read(cx)
2525 .visible_worktrees(cx)
2526 .next()
2527 .map(|worktree| worktree.read(cx).root_name_str().to_string())
2528 });
2529
2530 if let Some(screen_window) = cx
2531 .open_window(options, |_window, cx| {
2532 cx.new(|_cx| {
2533 AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
2534 })
2535 })
2536 .log_err()
2537 && let Some(pop_up) = screen_window.entity(cx).log_err()
2538 {
2539 self.notification_subscriptions
2540 .entry(screen_window)
2541 .or_insert_with(Vec::new)
2542 .push(cx.subscribe_in(&pop_up, window, {
2543 move |this, _, event, window, cx| match event {
2544 AgentNotificationEvent::Accepted => {
2545 let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
2546 else {
2547 log::error!("root view should be a MultiWorkspace");
2548 return;
2549 };
2550 cx.activate(true);
2551
2552 let workspace_handle = this.workspace.clone();
2553 let agent = this.connection_key.clone();
2554 let root_session_id = root_session_id.clone();
2555 let root_work_dirs = root_work_dirs.clone();
2556 let root_title = root_title.clone();
2557
2558 cx.defer(move |cx| {
2559 handle
2560 .update(cx, |multi_workspace, window, cx| {
2561 window.activate_window();
2562 if let Some(workspace) = workspace_handle.upgrade() {
2563 multi_workspace.activate(
2564 workspace.clone(),
2565 None,
2566 window,
2567 cx,
2568 );
2569 workspace.update(cx, |workspace, cx| {
2570 workspace.reveal_panel::<AgentPanel>(window, cx);
2571 if let Some(panel) =
2572 workspace.panel::<AgentPanel>(cx)
2573 {
2574 panel.update(cx, |panel, cx| {
2575 panel.load_agent_thread(
2576 agent.clone(),
2577 root_session_id.clone(),
2578 root_work_dirs.clone(),
2579 root_title.clone(),
2580 true,
2581 "agent_panel",
2582 window,
2583 cx,
2584 );
2585 });
2586 }
2587 workspace.focus_panel::<AgentPanel>(window, cx);
2588 });
2589 }
2590 })
2591 .log_err();
2592 });
2593
2594 this.dismiss_notifications(cx);
2595 }
2596 AgentNotificationEvent::Dismissed => {
2597 this.dismiss_notifications(cx);
2598 }
2599 }
2600 }));
2601
2602 self.notifications.push(screen_window);
2603
2604 // If the user manually refocuses the original window, dismiss the popup.
2605 self.notification_subscriptions
2606 .entry(screen_window)
2607 .or_insert_with(Vec::new)
2608 .push({
2609 let pop_up_weak = pop_up.downgrade();
2610
2611 cx.observe_window_activation(window, move |this, window, cx| {
2612 if this.agent_status_visible(window, cx)
2613 && let Some(pop_up) = pop_up_weak.upgrade()
2614 {
2615 pop_up.update(cx, |notification, cx| {
2616 notification.dismiss(cx);
2617 });
2618 }
2619 })
2620 });
2621 }
2622 }
2623
2624 fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2625 for window in self.notifications.drain(..) {
2626 window
2627 .update(cx, |_, window, _| {
2628 window.remove_window();
2629 })
2630 .ok();
2631
2632 self.notification_subscriptions.remove(&window);
2633 }
2634 }
2635
2636 fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2637 if let Some(entry_view_state) = self
2638 .active_thread()
2639 .map(|active| active.read(cx).entry_view_state.clone())
2640 {
2641 entry_view_state.update(cx, |entry_view_state, cx| {
2642 entry_view_state.agent_ui_font_size_changed(cx);
2643 });
2644 }
2645 }
2646
2647 pub(crate) fn insert_dragged_files(
2648 &self,
2649 paths: Vec<project::ProjectPath>,
2650 added_worktrees: Vec<Entity<project::Worktree>>,
2651 window: &mut Window,
2652 cx: &mut Context<Self>,
2653 ) {
2654 if let Some(active_thread) = self.active_thread() {
2655 active_thread.update(cx, |thread, cx| {
2656 thread.message_editor.update(cx, |editor, cx| {
2657 editor.insert_dragged_files(paths, added_worktrees, window, cx);
2658 editor.focus_handle(cx).focus(window, cx);
2659 })
2660 });
2661 }
2662 }
2663
2664 /// Inserts the selected text into the message editor or the message being
2665 /// edited, if any.
2666 pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
2667 if let Some(active_thread) = self.active_thread() {
2668 active_thread.update(cx, |thread, cx| {
2669 thread.active_editor(cx).update(cx, |editor, cx| {
2670 editor.insert_selections(window, cx);
2671 })
2672 });
2673 }
2674 }
2675
2676 fn current_model_name(&self, cx: &App) -> SharedString {
2677 // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
2678 // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
2679 // This provides better clarity about what refused the request
2680 if self.as_native_connection(cx).is_some() {
2681 self.root_thread_view()
2682 .and_then(|active| active.read(cx).model_selector.clone())
2683 .and_then(|selector| selector.read(cx).active_model(cx))
2684 .map(|model| model.name.clone())
2685 .unwrap_or_else(|| SharedString::from("The model"))
2686 } else {
2687 // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
2688 self.agent.agent_id().0
2689 }
2690 }
2691
2692 fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
2693 let message = message.into();
2694
2695 CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
2696 }
2697
2698 pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2699 let agent_id = self.agent.agent_id();
2700 if let Some(active) = self.root_thread_view() {
2701 active.update(cx, |active, cx| active.clear_thread_error(cx));
2702 }
2703 let this = cx.weak_entity();
2704 let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
2705 debug_panic!("This should not be possible");
2706 return;
2707 };
2708 window.defer(cx, |window, cx| {
2709 Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx);
2710 })
2711 }
2712
2713 pub fn history(&self) -> Option<&Entity<ThreadHistory>> {
2714 self.as_connected().and_then(|c| c.history.as_ref())
2715 }
2716}
2717
2718fn loading_contents_spinner(size: IconSize) -> AnyElement {
2719 Icon::new(IconName::LoadCircle)
2720 .size(size)
2721 .color(Color::Accent)
2722 .with_rotate_animation(3)
2723 .into_any_element()
2724}
2725
2726fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2727 if agent_name == agent::ZED_AGENT_ID.as_ref() {
2728 format!("Message the {} — @ to include context", agent_name)
2729 } else if has_commands {
2730 format!(
2731 "Message {} — @ to include context, / for commands",
2732 agent_name
2733 )
2734 } else {
2735 format!("Message {} — @ to include context", agent_name)
2736 }
2737}
2738
2739impl Focusable for ConversationView {
2740 fn focus_handle(&self, cx: &App) -> FocusHandle {
2741 match self.active_thread() {
2742 Some(thread) => thread.read(cx).focus_handle(cx),
2743 None => self.focus_handle.clone(),
2744 }
2745 }
2746}
2747
2748#[cfg(any(test, feature = "test-support"))]
2749impl ConversationView {
2750 /// Expands a tool call so its content is visible.
2751 /// This is primarily useful for visual testing.
2752 pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2753 if let Some(active) = self.active_thread() {
2754 active.update(cx, |active, _cx| {
2755 active.expanded_tool_calls.insert(tool_call_id);
2756 });
2757 cx.notify();
2758 }
2759 }
2760
2761 #[cfg(any(test, feature = "test-support"))]
2762 pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
2763 let Some(connected) = self.as_connected_mut() else {
2764 return;
2765 };
2766
2767 connected.conversation.update(cx, |conversation, _cx| {
2768 conversation.updated_at = Some(updated_at);
2769 });
2770 }
2771}
2772
2773impl Render for ConversationView {
2774 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2775 self.sync_queued_message_editors(window, cx);
2776
2777 v_flex()
2778 .track_focus(&self.focus_handle)
2779 .size_full()
2780 .bg(cx.theme().colors().panel_background)
2781 .child(match &self.server_state {
2782 ServerState::Loading { .. } => v_flex()
2783 .flex_1()
2784 .size_full()
2785 .items_center()
2786 .justify_center()
2787 .child(
2788 Label::new("Loading…").color(Color::Muted).with_animation(
2789 "loading-agent-label",
2790 Animation::new(Duration::from_secs(2))
2791 .repeat()
2792 .with_easing(pulsating_between(0.3, 0.7)),
2793 |label, delta| label.alpha(delta),
2794 ),
2795 )
2796 .into_any(),
2797 ServerState::LoadError { error: e, .. } => v_flex()
2798 .flex_1()
2799 .size_full()
2800 .items_center()
2801 .justify_end()
2802 .child(self.render_load_error(e, window, cx))
2803 .into_any(),
2804 ServerState::Connected(ConnectedServerState {
2805 connection,
2806 auth_state:
2807 AuthState::Unauthenticated {
2808 description,
2809 configuration_view,
2810 pending_auth_method,
2811 _subscription,
2812 },
2813 ..
2814 }) => v_flex()
2815 .flex_1()
2816 .size_full()
2817 .justify_end()
2818 .child(self.render_auth_required_state(
2819 connection,
2820 description.as_ref(),
2821 configuration_view.as_ref(),
2822 pending_auth_method.as_ref(),
2823 window,
2824 cx,
2825 ))
2826 .into_any_element(),
2827 ServerState::Connected(connected) => {
2828 if let Some(view) = connected.active_view() {
2829 view.clone().into_any_element()
2830 } else {
2831 debug_panic!("This state should never be reached");
2832 div().into_any_element()
2833 }
2834 }
2835 })
2836 }
2837}
2838
2839fn plan_label_markdown_style(
2840 status: &acp::PlanEntryStatus,
2841 window: &Window,
2842 cx: &App,
2843) -> MarkdownStyle {
2844 let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2845
2846 MarkdownStyle {
2847 base_text_style: TextStyle {
2848 color: cx.theme().colors().text_muted,
2849 strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2850 Some(gpui::StrikethroughStyle {
2851 thickness: px(1.),
2852 color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2853 })
2854 } else {
2855 None
2856 },
2857 ..default_md_style.base_text_style
2858 },
2859 ..default_md_style
2860 }
2861}
2862
2863#[cfg(test)]
2864pub(crate) mod tests {
2865 use acp_thread::{
2866 AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
2867 };
2868 use action_log::ActionLog;
2869 use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2870 use agent_client_protocol::SessionId;
2871 use agent_servers::FakeAcpAgentServer;
2872 use editor::MultiBufferOffset;
2873 use fs::FakeFs;
2874 use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2875 use parking_lot::Mutex;
2876 use project::Project;
2877 use serde_json::json;
2878 use settings::SettingsStore;
2879 use std::any::Any;
2880 use std::path::{Path, PathBuf};
2881 use std::rc::Rc;
2882 use std::sync::Arc;
2883 use workspace::{Item, MultiWorkspace};
2884
2885 use crate::agent_panel;
2886 use crate::thread_metadata_store::ThreadMetadataStore;
2887
2888 use super::*;
2889
2890 #[gpui::test]
2891 async fn test_drop(cx: &mut TestAppContext) {
2892 init_test(cx);
2893
2894 let (conversation_view, _cx) =
2895 setup_conversation_view(StubAgentServer::default_response(), cx).await;
2896 let weak_view = conversation_view.downgrade();
2897 drop(conversation_view);
2898 assert!(!weak_view.is_upgradable());
2899 }
2900
2901 #[gpui::test]
2902 async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2903 init_test(cx);
2904
2905 let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2906 panic!("expected prompt from external source to sanitize successfully");
2907 };
2908 let initial_content = AgentInitialContent::FromExternalSource(prompt);
2909
2910 let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2911 StubAgentServer::default_response(),
2912 initial_content,
2913 cx,
2914 )
2915 .await;
2916
2917 active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2918 assert!(view.show_external_source_prompt_warning);
2919 assert_eq!(view.thread.read(cx).entries().len(), 0);
2920 assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
2921 });
2922 }
2923
2924 #[gpui::test]
2925 async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
2926 init_test(cx);
2927
2928 let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2929 panic!("expected prompt from external source to sanitize successfully");
2930 };
2931 let initial_content = AgentInitialContent::FromExternalSource(prompt);
2932
2933 let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2934 StubAgentServer::default_response(),
2935 initial_content,
2936 cx,
2937 )
2938 .await;
2939
2940 active_thread(&conversation_view, cx)
2941 .update_in(cx, |view, window, cx| view.send(window, cx));
2942 cx.run_until_parked();
2943
2944 active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2945 assert!(!view.show_external_source_prompt_warning);
2946 assert_eq!(view.message_editor.read(cx).text(cx), "");
2947 assert_eq!(view.thread.read(cx).entries().len(), 2);
2948 });
2949 }
2950
2951 #[gpui::test]
2952 async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
2953 init_test(cx);
2954
2955 let (conversation_view, cx) =
2956 setup_conversation_view(StubAgentServer::default_response(), cx).await;
2957
2958 let message_editor = message_editor(&conversation_view, cx);
2959 message_editor.update_in(cx, |editor, window, cx| {
2960 editor.set_text("Hello", window, cx);
2961 });
2962
2963 cx.deactivate_window();
2964
2965 active_thread(&conversation_view, cx)
2966 .update_in(cx, |view, window, cx| view.send(window, cx));
2967
2968 cx.run_until_parked();
2969
2970 assert!(
2971 cx.windows()
2972 .iter()
2973 .any(|window| window.downcast::<AgentNotification>().is_some())
2974 );
2975 }
2976
2977 #[gpui::test]
2978 async fn test_notification_for_error(cx: &mut TestAppContext) {
2979 init_test(cx);
2980
2981 let server = FakeAcpAgentServer::new();
2982 let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
2983
2984 let message_editor = message_editor(&conversation_view, cx);
2985 message_editor.update_in(cx, |editor, window, cx| {
2986 editor.set_text("Hello", window, cx);
2987 });
2988
2989 cx.deactivate_window();
2990 server.fail_next_prompt();
2991
2992 active_thread(&conversation_view, cx)
2993 .update_in(cx, |view, window, cx| view.send(window, cx));
2994
2995 cx.run_until_parked();
2996
2997 assert!(
2998 cx.windows()
2999 .iter()
3000 .any(|window| window.downcast::<AgentNotification>().is_some())
3001 );
3002 }
3003
3004 #[gpui::test]
3005 async fn test_acp_server_exit_transitions_conversation_to_load_error_without_panic(
3006 cx: &mut TestAppContext,
3007 ) {
3008 init_test(cx);
3009
3010 let server = FakeAcpAgentServer::new();
3011 let close_session_count = server.close_session_count();
3012 let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
3013
3014 cx.run_until_parked();
3015
3016 server.simulate_server_exit();
3017 cx.run_until_parked();
3018
3019 conversation_view.read_with(cx, |view, _cx| {
3020 assert!(
3021 matches!(view.server_state, ServerState::LoadError { .. }),
3022 "Conversation should transition to LoadError when an ACP thread exits"
3023 );
3024 });
3025 assert_eq!(
3026 close_session_count.load(std::sync::atomic::Ordering::SeqCst),
3027 1,
3028 "ConversationView should close the ACP session after a thread exit"
3029 );
3030 }
3031
3032 #[gpui::test]
3033 async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
3034 init_test(cx);
3035
3036 let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
3037 let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
3038
3039 // Use a connection that provides a session list so ThreadHistory is created
3040 let (conversation_view, history, cx) = setup_thread_view_with_history(
3041 StubAgentServer::new(SessionHistoryConnection::new(vec![session_a.clone()])),
3042 cx,
3043 )
3044 .await;
3045
3046 // Initially has session_a from the connection's session list
3047 active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3048 assert_eq!(view.recent_history_entries.len(), 1);
3049 assert_eq!(
3050 view.recent_history_entries[0].session_id,
3051 session_a.session_id
3052 );
3053 });
3054
3055 // Swap to a different session list
3056 let list_b: Rc<dyn AgentSessionList> =
3057 Rc::new(StubSessionList::new(vec![session_b.clone()]));
3058 history.update(cx, |history, cx| {
3059 history.set_session_list(list_b, cx);
3060 });
3061 cx.run_until_parked();
3062
3063 active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3064 assert_eq!(view.recent_history_entries.len(), 1);
3065 assert_eq!(
3066 view.recent_history_entries[0].session_id,
3067 session_b.session_id
3068 );
3069 });
3070 }
3071
3072 #[gpui::test]
3073 async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) {
3074 init_test(cx);
3075
3076 let session = AgentSessionInfo::new(SessionId::new("history-session"));
3077 let (conversation_view, _history, cx) = setup_thread_view_with_history(
3078 StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])),
3079 cx,
3080 )
3081 .await;
3082
3083 active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3084 assert_eq!(view.recent_history_entries.len(), 1);
3085 assert_eq!(
3086 view.recent_history_entries[0].session_id,
3087 session.session_id
3088 );
3089 });
3090 }
3091
3092 #[gpui::test]
3093 async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
3094 init_test(cx);
3095
3096 let fs = FakeFs::new(cx.executor());
3097 let project = Project::test(fs, [], cx).await;
3098 let (multi_workspace, cx) =
3099 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3100 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3101
3102 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3103 let connection_store =
3104 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3105
3106 let conversation_view = cx.update(|window, cx| {
3107 cx.new(|cx| {
3108 ConversationView::new(
3109 Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
3110 connection_store,
3111 Agent::Custom { id: "Test".into() },
3112 Some(SessionId::new("resume-session")),
3113 None,
3114 None,
3115 None,
3116 None,
3117 workspace.downgrade(),
3118 project,
3119 Some(thread_store),
3120 None,
3121 "agent_panel",
3122 window,
3123 cx,
3124 )
3125 })
3126 });
3127
3128 cx.run_until_parked();
3129
3130 conversation_view.read_with(cx, |view, cx| {
3131 let state = view.active_thread().unwrap();
3132 assert!(state.read(cx).resumed_without_history);
3133 assert_eq!(state.read(cx).list_state.item_count(), 0);
3134 });
3135 }
3136
3137 #[derive(Clone)]
3138 struct RestoredAvailableCommandsConnection;
3139
3140 impl AgentConnection for RestoredAvailableCommandsConnection {
3141 fn agent_id(&self) -> AgentId {
3142 AgentId::new("restored-available-commands")
3143 }
3144
3145 fn telemetry_id(&self) -> SharedString {
3146 "restored-available-commands".into()
3147 }
3148
3149 fn new_session(
3150 self: Rc<Self>,
3151 project: Entity<Project>,
3152 _work_dirs: PathList,
3153 cx: &mut App,
3154 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3155 let thread = build_test_thread(
3156 self,
3157 project,
3158 "RestoredAvailableCommandsConnection",
3159 SessionId::new("new-session"),
3160 cx,
3161 );
3162 Task::ready(Ok(thread))
3163 }
3164
3165 fn supports_load_session(&self) -> bool {
3166 true
3167 }
3168
3169 fn load_session(
3170 self: Rc<Self>,
3171 session_id: acp::SessionId,
3172 project: Entity<Project>,
3173 _work_dirs: PathList,
3174 _title: Option<SharedString>,
3175 cx: &mut App,
3176 ) -> Task<gpui::Result<Entity<AcpThread>>> {
3177 let thread = build_test_thread(
3178 self,
3179 project,
3180 "RestoredAvailableCommandsConnection",
3181 session_id,
3182 cx,
3183 );
3184
3185 thread
3186 .update(cx, |thread, cx| {
3187 thread.handle_session_update(
3188 acp::SessionUpdate::AvailableCommandsUpdate(
3189 acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
3190 "help", "Get help",
3191 )]),
3192 ),
3193 cx,
3194 )
3195 })
3196 .expect("available commands update should succeed");
3197
3198 Task::ready(Ok(thread))
3199 }
3200
3201 fn auth_methods(&self) -> &[acp::AuthMethod] {
3202 &[]
3203 }
3204
3205 fn authenticate(
3206 &self,
3207 _method_id: acp::AuthMethodId,
3208 _cx: &mut App,
3209 ) -> Task<gpui::Result<()>> {
3210 Task::ready(Ok(()))
3211 }
3212
3213 fn prompt(
3214 &self,
3215 _id: acp_thread::UserMessageId,
3216 _params: acp::PromptRequest,
3217 _cx: &mut App,
3218 ) -> Task<gpui::Result<acp::PromptResponse>> {
3219 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3220 }
3221
3222 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3223
3224 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3225 self
3226 }
3227 }
3228
3229 #[gpui::test]
3230 async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) {
3231 init_test(cx);
3232
3233 let fs = FakeFs::new(cx.executor());
3234 let project = Project::test(fs, [], cx).await;
3235 let (multi_workspace, cx) =
3236 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3237 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3238
3239 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3240 let connection_store =
3241 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3242
3243 let conversation_view = cx.update(|window, cx| {
3244 cx.new(|cx| {
3245 ConversationView::new(
3246 Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3247 connection_store,
3248 Agent::Custom { id: "Test".into() },
3249 Some(SessionId::new("restored-session")),
3250 None,
3251 None,
3252 None,
3253 None,
3254 workspace.downgrade(),
3255 project,
3256 Some(thread_store),
3257 None,
3258 "agent_panel",
3259 window,
3260 cx,
3261 )
3262 })
3263 });
3264
3265 cx.run_until_parked();
3266
3267 let message_editor = message_editor(&conversation_view, cx);
3268 let editor =
3269 message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
3270 let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
3271
3272 active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3273 let available_commands = view
3274 .session_capabilities
3275 .read()
3276 .available_commands()
3277 .to_vec();
3278 assert_eq!(available_commands.len(), 1);
3279 assert_eq!(available_commands[0].name.as_str(), "help");
3280 assert_eq!(available_commands[0].description.as_str(), "Get help");
3281 });
3282
3283 assert_eq!(
3284 placeholder,
3285 Some("Message Test — @ to include context, / for commands".to_string())
3286 );
3287
3288 message_editor.update_in(cx, |editor, window, cx| {
3289 editor.set_text("/help", window, cx);
3290 });
3291
3292 let contents_result = message_editor
3293 .update(cx, |editor, cx| editor.contents(false, cx))
3294 .await;
3295
3296 assert!(contents_result.is_ok());
3297 }
3298
3299 #[gpui::test]
3300 async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
3301 init_test(cx);
3302
3303 let fs = FakeFs::new(cx.executor());
3304 fs.insert_tree(
3305 "/project",
3306 json!({
3307 "subdir": {
3308 "file.txt": "hello"
3309 }
3310 }),
3311 )
3312 .await;
3313 let project = Project::test(fs, [Path::new("/project")], cx).await;
3314 let (multi_workspace, cx) =
3315 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3316 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3317
3318 let connection = CwdCapturingConnection::new();
3319 let captured_cwd = connection.captured_work_dirs.clone();
3320
3321 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3322 let connection_store =
3323 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3324
3325 let _conversation_view = cx.update(|window, cx| {
3326 cx.new(|cx| {
3327 ConversationView::new(
3328 Rc::new(StubAgentServer::new(connection)),
3329 connection_store,
3330 Agent::Custom { id: "Test".into() },
3331 Some(SessionId::new("session-1")),
3332 None,
3333 Some(PathList::new(&[PathBuf::from("/project/subdir")])),
3334 None,
3335 None,
3336 workspace.downgrade(),
3337 project,
3338 Some(thread_store),
3339 None,
3340 "agent_panel",
3341 window,
3342 cx,
3343 )
3344 })
3345 });
3346
3347 cx.run_until_parked();
3348
3349 assert_eq!(
3350 captured_cwd.lock().as_ref().unwrap(),
3351 &PathList::new(&[Path::new("/project/subdir")]),
3352 "Should use session cwd when it's inside the project"
3353 );
3354 }
3355
3356 #[gpui::test]
3357 async fn test_refusal_handling(cx: &mut TestAppContext) {
3358 init_test(cx);
3359
3360 let (conversation_view, cx) =
3361 setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3362
3363 let message_editor = message_editor(&conversation_view, cx);
3364 message_editor.update_in(cx, |editor, window, cx| {
3365 editor.set_text("Do something harmful", window, cx);
3366 });
3367
3368 active_thread(&conversation_view, cx)
3369 .update_in(cx, |view, window, cx| view.send(window, cx));
3370
3371 cx.run_until_parked();
3372
3373 // Check that the refusal error is set
3374 conversation_view.read_with(cx, |thread_view, cx| {
3375 let state = thread_view.active_thread().unwrap();
3376 assert!(
3377 matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3378 "Expected refusal error to be set"
3379 );
3380 });
3381 }
3382
3383 #[gpui::test]
3384 async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3385 init_test(cx);
3386
3387 let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
3388
3389 conversation_view.read_with(cx, |view, cx| {
3390 let title = view.title(cx);
3391 assert_eq!(
3392 title.as_ref(),
3393 "Error Loading Codex CLI",
3394 "Tab title should show the agent name with an error prefix"
3395 );
3396 match &view.server_state {
3397 ServerState::LoadError {
3398 error: LoadError::Other(msg),
3399 ..
3400 } => {
3401 assert!(
3402 msg.contains("Invalid gzip header"),
3403 "Error callout should contain the underlying extraction error, got: {msg}"
3404 );
3405 }
3406 other => panic!(
3407 "Expected LoadError::Other, got: {}",
3408 match other {
3409 ServerState::Loading { .. } => "Loading (stuck!)",
3410 ServerState::LoadError { .. } => "LoadError (wrong variant)",
3411 ServerState::Connected(_) => "Connected",
3412 }
3413 ),
3414 }
3415 });
3416 }
3417
3418 #[gpui::test]
3419 async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3420 init_test(cx);
3421
3422 let connection = AuthGatedAgentConnection::new();
3423 let (conversation_view, cx) =
3424 setup_conversation_view(StubAgentServer::new(connection), cx).await;
3425
3426 // When new_session returns AuthRequired, the server should transition
3427 // to Connected + Unauthenticated rather than getting stuck in Loading.
3428 conversation_view.read_with(cx, |view, _cx| {
3429 let connected = view
3430 .as_connected()
3431 .expect("Should be in Connected state even though auth is required");
3432 assert!(
3433 !connected.auth_state.is_ok(),
3434 "Auth state should be Unauthenticated"
3435 );
3436 assert!(
3437 connected.active_id.is_none(),
3438 "There should be no active thread since no session was created"
3439 );
3440 assert!(
3441 connected.threads.is_empty(),
3442 "There should be no threads since no session was created"
3443 );
3444 });
3445
3446 conversation_view.read_with(cx, |view, _cx| {
3447 assert!(
3448 view.active_thread().is_none(),
3449 "active_thread() should be None when unauthenticated without a session"
3450 );
3451 });
3452
3453 // Authenticate using the real authenticate flow on ConnectionView.
3454 // This calls connection.authenticate(), which flips the internal flag,
3455 // then on success triggers reset() -> new_session() which now succeeds.
3456 conversation_view.update_in(cx, |view, window, cx| {
3457 view.authenticate(
3458 acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3459 window,
3460 cx,
3461 );
3462 });
3463 cx.run_until_parked();
3464
3465 // After auth, the server should have an active thread in the Ok state.
3466 conversation_view.read_with(cx, |view, cx| {
3467 let connected = view
3468 .as_connected()
3469 .expect("Should still be in Connected state after auth");
3470 assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3471 assert!(
3472 connected.active_id.is_some(),
3473 "There should be an active thread after successful auth"
3474 );
3475 assert_eq!(
3476 connected.threads.len(),
3477 1,
3478 "There should be exactly one thread"
3479 );
3480
3481 let active = view
3482 .active_thread()
3483 .expect("active_thread() should return the new thread");
3484 assert!(
3485 active.read(cx).thread_error.is_none(),
3486 "The new thread should have no errors"
3487 );
3488 });
3489 }
3490
3491 #[gpui::test]
3492 async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3493 init_test(cx);
3494
3495 let tool_call_id = acp::ToolCallId::new("1");
3496 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3497 .kind(acp::ToolKind::Edit)
3498 .content(vec!["hi".into()]);
3499 let connection =
3500 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3501 tool_call_id,
3502 PermissionOptions::Flat(vec![acp::PermissionOption::new(
3503 "1",
3504 "Allow",
3505 acp::PermissionOptionKind::AllowOnce,
3506 )]),
3507 )]));
3508
3509 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3510
3511 let (conversation_view, cx) =
3512 setup_conversation_view(StubAgentServer::new(connection), cx).await;
3513
3514 let message_editor = message_editor(&conversation_view, cx);
3515 message_editor.update_in(cx, |editor, window, cx| {
3516 editor.set_text("Hello", window, cx);
3517 });
3518
3519 cx.deactivate_window();
3520
3521 active_thread(&conversation_view, cx)
3522 .update_in(cx, |view, window, cx| view.send(window, cx));
3523
3524 cx.run_until_parked();
3525
3526 assert!(
3527 cx.windows()
3528 .iter()
3529 .any(|window| window.downcast::<AgentNotification>().is_some())
3530 );
3531 }
3532
3533 #[gpui::test]
3534 async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3535 init_test(cx);
3536
3537 let (conversation_view, cx) =
3538 setup_conversation_view(StubAgentServer::default_response(), cx).await;
3539
3540 add_to_workspace(conversation_view.clone(), cx);
3541
3542 let message_editor = message_editor(&conversation_view, cx);
3543
3544 message_editor.update_in(cx, |editor, window, cx| {
3545 editor.set_text("Hello", window, cx);
3546 });
3547
3548 // Window is active (don't deactivate), but panel will be hidden
3549 // Note: In the test environment, the panel is not actually added to the dock,
3550 // so is_agent_panel_hidden will return true
3551
3552 active_thread(&conversation_view, cx)
3553 .update_in(cx, |view, window, cx| view.send(window, cx));
3554
3555 cx.run_until_parked();
3556
3557 // Should show notification because window is active but panel is hidden
3558 assert!(
3559 cx.windows()
3560 .iter()
3561 .any(|window| window.downcast::<AgentNotification>().is_some()),
3562 "Expected notification when panel is hidden"
3563 );
3564 }
3565
3566 #[gpui::test]
3567 async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3568 init_test(cx);
3569
3570 let (conversation_view, cx) =
3571 setup_conversation_view(StubAgentServer::default_response(), cx).await;
3572
3573 let message_editor = message_editor(&conversation_view, cx);
3574 message_editor.update_in(cx, |editor, window, cx| {
3575 editor.set_text("Hello", window, cx);
3576 });
3577
3578 // Deactivate window - should show notification regardless of setting
3579 cx.deactivate_window();
3580
3581 active_thread(&conversation_view, cx)
3582 .update_in(cx, |view, window, cx| view.send(window, cx));
3583
3584 cx.run_until_parked();
3585
3586 // Should still show notification when window is inactive (existing behavior)
3587 assert!(
3588 cx.windows()
3589 .iter()
3590 .any(|window| window.downcast::<AgentNotification>().is_some()),
3591 "Expected notification when window is inactive"
3592 );
3593 }
3594
3595 #[gpui::test]
3596 async fn test_notification_when_different_conversation_is_active_in_visible_panel(
3597 cx: &mut TestAppContext,
3598 ) {
3599 init_test(cx);
3600
3601 let fs = FakeFs::new(cx.executor());
3602
3603 cx.update(|cx| {
3604 cx.update_flags(true, vec!["agent-v2".to_string()]);
3605 agent::ThreadStore::init_global(cx);
3606 language_model::LanguageModelRegistry::test(cx);
3607 <dyn Fs>::set_global(fs.clone(), cx);
3608 });
3609
3610 let project = Project::test(fs, [], cx).await;
3611 let multi_workspace_handle =
3612 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3613
3614 let workspace = multi_workspace_handle
3615 .read_with(cx, |mw, _cx| mw.workspace().clone())
3616 .unwrap();
3617
3618 let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3619
3620 let panel = workspace.update_in(cx, |workspace, window, cx| {
3621 let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
3622 workspace.add_panel(panel.clone(), window, cx);
3623 workspace.focus_panel::<crate::AgentPanel>(window, cx);
3624 panel
3625 });
3626
3627 cx.run_until_parked();
3628
3629 panel.update_in(cx, |panel, window, cx| {
3630 panel.open_external_thread_with_server(
3631 Rc::new(StubAgentServer::default_response()),
3632 window,
3633 cx,
3634 );
3635 });
3636
3637 cx.run_until_parked();
3638
3639 panel.read_with(cx, |panel, cx| {
3640 assert!(crate::AgentPanel::is_visible(&workspace, cx));
3641 assert!(panel.active_conversation_view().is_some());
3642 });
3643
3644 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3645 let connection_store =
3646 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3647
3648 let conversation_view = cx.update(|window, cx| {
3649 cx.new(|cx| {
3650 ConversationView::new(
3651 Rc::new(StubAgentServer::default_response()),
3652 connection_store,
3653 Agent::Custom { id: "Test".into() },
3654 None,
3655 None,
3656 None,
3657 None,
3658 None,
3659 workspace.downgrade(),
3660 project.clone(),
3661 Some(thread_store),
3662 None,
3663 "agent_panel",
3664 window,
3665 cx,
3666 )
3667 })
3668 });
3669
3670 cx.run_until_parked();
3671
3672 panel.read_with(cx, |panel, _cx| {
3673 assert_ne!(
3674 panel
3675 .active_conversation_view()
3676 .map(|view| view.entity_id()),
3677 Some(conversation_view.entity_id()),
3678 "The visible panel should still be showing a different conversation"
3679 );
3680 });
3681
3682 let message_editor = message_editor(&conversation_view, cx);
3683 message_editor.update_in(cx, |editor, window, cx| {
3684 editor.set_text("Hello", window, cx);
3685 });
3686
3687 active_thread(&conversation_view, cx)
3688 .update_in(cx, |view, window, cx| view.send(window, cx));
3689
3690 cx.run_until_parked();
3691
3692 assert!(
3693 cx.windows()
3694 .iter()
3695 .any(|window| window.downcast::<AgentNotification>().is_some()),
3696 "Expected notification when a different conversation is active in the visible panel"
3697 );
3698 }
3699
3700 #[gpui::test]
3701 async fn test_no_notification_when_sidebar_open_but_different_thread_focused(
3702 cx: &mut TestAppContext,
3703 ) {
3704 init_test(cx);
3705
3706 let fs = FakeFs::new(cx.executor());
3707
3708 cx.update(|cx| {
3709 cx.update_flags(true, vec!["agent-v2".to_string()]);
3710 agent::ThreadStore::init_global(cx);
3711 language_model::LanguageModelRegistry::test(cx);
3712 <dyn Fs>::set_global(fs.clone(), cx);
3713 });
3714
3715 let project = Project::test(fs, [], cx).await;
3716 let multi_workspace_handle =
3717 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3718
3719 let workspace = multi_workspace_handle
3720 .read_with(cx, |mw, _cx| mw.workspace().clone())
3721 .unwrap();
3722
3723 let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3724
3725 // Open the sidebar so that sidebar_open() returns true.
3726 multi_workspace_handle
3727 .update(cx, |mw, _window, cx| {
3728 mw.open_sidebar(cx);
3729 })
3730 .unwrap();
3731
3732 cx.run_until_parked();
3733
3734 assert!(
3735 multi_workspace_handle
3736 .read_with(cx, |mw, _cx| mw.sidebar_open())
3737 .unwrap(),
3738 "Sidebar should be open"
3739 );
3740
3741 // Create a conversation view that is NOT the active one in the panel.
3742 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3743 let connection_store =
3744 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3745
3746 let conversation_view = cx.update(|window, cx| {
3747 cx.new(|cx| {
3748 ConversationView::new(
3749 Rc::new(StubAgentServer::default_response()),
3750 connection_store,
3751 Agent::Custom { id: "Test".into() },
3752 None,
3753 None,
3754 None,
3755 None,
3756 None,
3757 workspace.downgrade(),
3758 project.clone(),
3759 Some(thread_store),
3760 None,
3761 "agent_panel",
3762 window,
3763 cx,
3764 )
3765 })
3766 });
3767
3768 cx.run_until_parked();
3769
3770 let message_editor = message_editor(&conversation_view, cx);
3771 message_editor.update_in(cx, |editor, window, cx| {
3772 editor.set_text("Hello", window, cx);
3773 });
3774
3775 active_thread(&conversation_view, cx)
3776 .update_in(cx, |view, window, cx| view.send(window, cx));
3777
3778 cx.run_until_parked();
3779
3780 assert!(
3781 !cx.windows()
3782 .iter()
3783 .any(|window| window.downcast::<AgentNotification>().is_some()),
3784 "Expected no notification when the sidebar is open, even if focused on another thread"
3785 );
3786 }
3787
3788 #[gpui::test]
3789 async fn test_notification_when_workspace_is_background_in_multi_workspace(
3790 cx: &mut TestAppContext,
3791 ) {
3792 init_test(cx);
3793
3794 // Enable multi-workspace feature flag and init globals needed by AgentPanel
3795 let fs = FakeFs::new(cx.executor());
3796
3797 cx.update(|cx| {
3798 agent::ThreadStore::init_global(cx);
3799 language_model::LanguageModelRegistry::test(cx);
3800 <dyn Fs>::set_global(fs.clone(), cx);
3801 });
3802
3803 let project1 = Project::test(fs.clone(), [], cx).await;
3804
3805 // Create a MultiWorkspace window with one workspace
3806 let multi_workspace_handle =
3807 cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
3808
3809 // Get workspace 1 (the initial workspace)
3810 let workspace1 = multi_workspace_handle
3811 .read_with(cx, |mw, _cx| mw.workspace().clone())
3812 .unwrap();
3813
3814 let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3815
3816 let panel = workspace1.update_in(cx, |workspace, window, cx| {
3817 let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
3818 workspace.add_panel(panel.clone(), window, cx);
3819
3820 // Open the dock and activate the agent panel so it's visible
3821 workspace.focus_panel::<crate::AgentPanel>(window, cx);
3822 panel
3823 });
3824
3825 cx.run_until_parked();
3826
3827 panel.update_in(cx, |panel, window, cx| {
3828 panel.open_external_thread_with_server(
3829 Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3830 window,
3831 cx,
3832 );
3833 });
3834
3835 cx.run_until_parked();
3836
3837 cx.read(|cx| {
3838 assert!(
3839 crate::AgentPanel::is_visible(&workspace1, cx),
3840 "AgentPanel should be visible in workspace1's dock"
3841 );
3842 });
3843
3844 // Set up thread view in workspace 1
3845 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3846 let connection_store =
3847 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
3848
3849 let conversation_view = cx.update(|window, cx| {
3850 cx.new(|cx| {
3851 ConversationView::new(
3852 Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3853 connection_store,
3854 Agent::Custom { id: "Test".into() },
3855 None,
3856 None,
3857 None,
3858 None,
3859 None,
3860 workspace1.downgrade(),
3861 project1.clone(),
3862 Some(thread_store),
3863 None,
3864 "agent_panel",
3865 window,
3866 cx,
3867 )
3868 })
3869 });
3870 cx.run_until_parked();
3871
3872 let root_session_id = conversation_view
3873 .read_with(cx, |view, cx| {
3874 view.root_thread_view()
3875 .map(|thread| thread.read(cx).thread.read(cx).session_id().clone())
3876 })
3877 .expect("Conversation view should have a root thread");
3878
3879 let message_editor = message_editor(&conversation_view, cx);
3880 message_editor.update_in(cx, |editor, window, cx| {
3881 editor.set_text("Hello", window, cx);
3882 });
3883
3884 // Create a second workspace and switch to it.
3885 // This makes workspace1 the "background" workspace.
3886 let project2 = Project::test(fs, [], cx).await;
3887 multi_workspace_handle
3888 .update(cx, |mw, window, cx| {
3889 mw.test_add_workspace(project2, window, cx);
3890 })
3891 .unwrap();
3892
3893 cx.run_until_parked();
3894
3895 // Verify workspace1 is no longer the active workspace
3896 multi_workspace_handle
3897 .read_with(cx, |mw, _cx| {
3898 assert_ne!(mw.workspace(), &workspace1);
3899 })
3900 .unwrap();
3901
3902 // Window is active, agent panel is visible in workspace1, but workspace1
3903 // is in the background. The notification should show because the user
3904 // can't actually see the agent panel.
3905 active_thread(&conversation_view, cx)
3906 .update_in(cx, |view, window, cx| view.send(window, cx));
3907
3908 cx.run_until_parked();
3909
3910 assert!(
3911 cx.windows()
3912 .iter()
3913 .any(|window| window.downcast::<AgentNotification>().is_some()),
3914 "Expected notification when workspace is in background within MultiWorkspace"
3915 );
3916
3917 // Also verify: clicking "View Panel" should switch to workspace1.
3918 cx.windows()
3919 .iter()
3920 .find_map(|window| window.downcast::<AgentNotification>())
3921 .unwrap()
3922 .update(cx, |window, _, cx| window.accept(cx))
3923 .unwrap();
3924
3925 cx.run_until_parked();
3926
3927 multi_workspace_handle
3928 .read_with(cx, |mw, _cx| {
3929 assert_eq!(
3930 mw.workspace(),
3931 &workspace1,
3932 "Expected workspace1 to become the active workspace after accepting notification"
3933 );
3934 })
3935 .unwrap();
3936
3937 panel.read_with(cx, |panel, cx| {
3938 let active_session_id = panel
3939 .active_agent_thread(cx)
3940 .map(|thread| thread.read(cx).session_id().clone());
3941 assert_eq!(
3942 active_session_id,
3943 Some(root_session_id),
3944 "Expected accepting the notification to load the notified thread in AgentPanel"
3945 );
3946 });
3947 }
3948
3949 #[gpui::test]
3950 async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
3951 init_test(cx);
3952
3953 // Set notify_when_agent_waiting to Never
3954 cx.update(|cx| {
3955 AgentSettings::override_global(
3956 AgentSettings {
3957 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
3958 ..AgentSettings::get_global(cx).clone()
3959 },
3960 cx,
3961 );
3962 });
3963
3964 let (conversation_view, cx) =
3965 setup_conversation_view(StubAgentServer::default_response(), cx).await;
3966
3967 let message_editor = message_editor(&conversation_view, cx);
3968 message_editor.update_in(cx, |editor, window, cx| {
3969 editor.set_text("Hello", window, cx);
3970 });
3971
3972 // Window is active
3973
3974 active_thread(&conversation_view, cx)
3975 .update_in(cx, |view, window, cx| view.send(window, cx));
3976
3977 cx.run_until_parked();
3978
3979 // Should NOT show notification because notify_when_agent_waiting is Never
3980 assert!(
3981 !cx.windows()
3982 .iter()
3983 .any(|window| window.downcast::<AgentNotification>().is_some()),
3984 "Expected no notification when notify_when_agent_waiting is Never"
3985 );
3986 }
3987
3988 #[gpui::test]
3989 async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
3990 init_test(cx);
3991
3992 let (conversation_view, cx) =
3993 setup_conversation_view(StubAgentServer::default_response(), cx).await;
3994
3995 let weak_view = conversation_view.downgrade();
3996
3997 let message_editor = message_editor(&conversation_view, cx);
3998 message_editor.update_in(cx, |editor, window, cx| {
3999 editor.set_text("Hello", window, cx);
4000 });
4001
4002 cx.deactivate_window();
4003
4004 active_thread(&conversation_view, cx)
4005 .update_in(cx, |view, window, cx| view.send(window, cx));
4006
4007 cx.run_until_parked();
4008
4009 // Verify notification is shown
4010 assert!(
4011 cx.windows()
4012 .iter()
4013 .any(|window| window.downcast::<AgentNotification>().is_some()),
4014 "Expected notification to be shown"
4015 );
4016
4017 // Drop the thread view (simulating navigation to a new thread)
4018 drop(conversation_view);
4019 drop(message_editor);
4020 // Trigger an update to flush effects, which will call release_dropped_entities
4021 cx.update(|_window, _cx| {});
4022 cx.run_until_parked();
4023
4024 // Verify the entity was actually released
4025 assert!(
4026 !weak_view.is_upgradable(),
4027 "Thread view entity should be released after dropping"
4028 );
4029
4030 // The notification should be automatically closed via on_release
4031 assert!(
4032 !cx.windows()
4033 .iter()
4034 .any(|window| window.downcast::<AgentNotification>().is_some()),
4035 "Notification should be closed when thread view is dropped"
4036 );
4037 }
4038
4039 async fn setup_conversation_view(
4040 agent: impl AgentServer + 'static,
4041 cx: &mut TestAppContext,
4042 ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4043 let (conversation_view, _history, cx) =
4044 setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
4045 (conversation_view, cx)
4046 }
4047
4048 async fn setup_thread_view_with_history(
4049 agent: impl AgentServer + 'static,
4050 cx: &mut TestAppContext,
4051 ) -> (
4052 Entity<ConversationView>,
4053 Entity<ThreadHistory>,
4054 &mut VisualTestContext,
4055 ) {
4056 let (conversation_view, history, cx) =
4057 setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
4058 (conversation_view, history.expect("Missing history"), cx)
4059 }
4060
4061 async fn setup_conversation_view_with_initial_content(
4062 agent: impl AgentServer + 'static,
4063 initial_content: AgentInitialContent,
4064 cx: &mut TestAppContext,
4065 ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4066 let (conversation_view, _history, cx) =
4067 setup_conversation_view_with_history_and_initial_content(
4068 agent,
4069 Some(initial_content),
4070 cx,
4071 )
4072 .await;
4073 (conversation_view, cx)
4074 }
4075
4076 async fn setup_conversation_view_with_history_and_initial_content(
4077 agent: impl AgentServer + 'static,
4078 initial_content: Option<AgentInitialContent>,
4079 cx: &mut TestAppContext,
4080 ) -> (
4081 Entity<ConversationView>,
4082 Option<Entity<ThreadHistory>>,
4083 &mut VisualTestContext,
4084 ) {
4085 let fs = FakeFs::new(cx.executor());
4086 let project = Project::test(fs, [], cx).await;
4087 let (multi_workspace, cx) =
4088 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4089 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4090
4091 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4092 let connection_store =
4093 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4094
4095 let agent_key = Agent::Custom { id: "Test".into() };
4096
4097 let conversation_view = cx.update(|window, cx| {
4098 cx.new(|cx| {
4099 ConversationView::new(
4100 Rc::new(agent),
4101 connection_store.clone(),
4102 agent_key.clone(),
4103 None,
4104 None,
4105 None,
4106 None,
4107 initial_content,
4108 workspace.downgrade(),
4109 project,
4110 Some(thread_store),
4111 None,
4112 "agent_panel",
4113 window,
4114 cx,
4115 )
4116 })
4117 });
4118 cx.run_until_parked();
4119
4120 let history = cx.update(|_window, cx| {
4121 connection_store
4122 .read(cx)
4123 .entry(&agent_key)
4124 .and_then(|e| e.read(cx).history().cloned())
4125 });
4126
4127 (conversation_view, history, cx)
4128 }
4129
4130 fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
4131 let workspace =
4132 conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
4133
4134 workspace
4135 .update_in(cx, |workspace, window, cx| {
4136 workspace.add_item_to_active_pane(
4137 Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
4138 None,
4139 true,
4140 window,
4141 cx,
4142 );
4143 })
4144 .unwrap();
4145 }
4146
4147 struct ThreadViewItem(Entity<ConversationView>);
4148
4149 impl Item for ThreadViewItem {
4150 type Event = ();
4151
4152 fn include_in_nav_history() -> bool {
4153 false
4154 }
4155
4156 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
4157 "Test".into()
4158 }
4159 }
4160
4161 impl EventEmitter<()> for ThreadViewItem {}
4162
4163 impl Focusable for ThreadViewItem {
4164 fn focus_handle(&self, cx: &App) -> FocusHandle {
4165 self.0.read(cx).focus_handle(cx)
4166 }
4167 }
4168
4169 impl Render for ThreadViewItem {
4170 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4171 // Render the title editor in the element tree too. In the real app
4172 // it is part of the agent panel
4173 let title_editor = self
4174 .0
4175 .read(cx)
4176 .active_thread()
4177 .map(|t| t.read(cx).title_editor.clone());
4178
4179 v_flex().children(title_editor).child(self.0.clone())
4180 }
4181 }
4182
4183 pub(crate) struct StubAgentServer<C> {
4184 connection: C,
4185 }
4186
4187 impl<C> StubAgentServer<C> {
4188 pub(crate) fn new(connection: C) -> Self {
4189 Self { connection }
4190 }
4191 }
4192
4193 impl StubAgentServer<StubAgentConnection> {
4194 pub(crate) fn default_response() -> Self {
4195 let conn = StubAgentConnection::new();
4196 conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4197 acp::ContentChunk::new("Default response".into()),
4198 )]);
4199 Self::new(conn)
4200 }
4201 }
4202
4203 impl<C> AgentServer for StubAgentServer<C>
4204 where
4205 C: 'static + AgentConnection + Send + Clone,
4206 {
4207 fn logo(&self) -> ui::IconName {
4208 ui::IconName::ZedAgent
4209 }
4210
4211 fn agent_id(&self) -> AgentId {
4212 "Test".into()
4213 }
4214
4215 fn connect(
4216 &self,
4217 _delegate: AgentServerDelegate,
4218 _project: Entity<Project>,
4219 _cx: &mut App,
4220 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4221 Task::ready(Ok(Rc::new(self.connection.clone())))
4222 }
4223
4224 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4225 self
4226 }
4227 }
4228
4229 struct FailingAgentServer;
4230
4231 impl AgentServer for FailingAgentServer {
4232 fn logo(&self) -> ui::IconName {
4233 ui::IconName::AiOpenAi
4234 }
4235
4236 fn agent_id(&self) -> AgentId {
4237 AgentId::new("Codex CLI")
4238 }
4239
4240 fn connect(
4241 &self,
4242 _delegate: AgentServerDelegate,
4243 _project: Entity<Project>,
4244 _cx: &mut App,
4245 ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4246 Task::ready(Err(anyhow!(
4247 "extracting downloaded asset for \
4248 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
4249 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
4250 failed to iterate over archive: Invalid gzip header"
4251 )))
4252 }
4253
4254 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4255 self
4256 }
4257 }
4258
4259 #[derive(Clone)]
4260 struct StubSessionList {
4261 sessions: Vec<AgentSessionInfo>,
4262 }
4263
4264 impl StubSessionList {
4265 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
4266 Self { sessions }
4267 }
4268 }
4269
4270 impl AgentSessionList for StubSessionList {
4271 fn list_sessions(
4272 &self,
4273 _request: AgentSessionListRequest,
4274 _cx: &mut App,
4275 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
4276 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
4277 }
4278
4279 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4280 self
4281 }
4282 }
4283
4284 #[derive(Clone)]
4285 struct SessionHistoryConnection {
4286 sessions: Vec<AgentSessionInfo>,
4287 }
4288
4289 impl SessionHistoryConnection {
4290 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
4291 Self { sessions }
4292 }
4293 }
4294
4295 fn build_test_thread(
4296 connection: Rc<dyn AgentConnection>,
4297 project: Entity<Project>,
4298 name: &'static str,
4299 session_id: SessionId,
4300 cx: &mut App,
4301 ) -> Entity<AcpThread> {
4302 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4303 cx.new(|cx| {
4304 AcpThread::new(
4305 None,
4306 Some(name.into()),
4307 None,
4308 connection,
4309 project,
4310 action_log,
4311 session_id,
4312 watch::Receiver::constant(
4313 acp::PromptCapabilities::new()
4314 .image(true)
4315 .audio(true)
4316 .embedded_context(true),
4317 ),
4318 cx,
4319 )
4320 })
4321 }
4322
4323 impl AgentConnection for SessionHistoryConnection {
4324 fn agent_id(&self) -> AgentId {
4325 AgentId::new("history-connection")
4326 }
4327
4328 fn telemetry_id(&self) -> SharedString {
4329 "history-connection".into()
4330 }
4331
4332 fn new_session(
4333 self: Rc<Self>,
4334 project: Entity<Project>,
4335 _work_dirs: PathList,
4336 cx: &mut App,
4337 ) -> Task<anyhow::Result<Entity<AcpThread>>> {
4338 let thread = build_test_thread(
4339 self,
4340 project,
4341 "SessionHistoryConnection",
4342 SessionId::new("history-session"),
4343 cx,
4344 );
4345 Task::ready(Ok(thread))
4346 }
4347
4348 fn supports_load_session(&self) -> bool {
4349 true
4350 }
4351
4352 fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
4353 Some(Rc::new(StubSessionList::new(self.sessions.clone())))
4354 }
4355
4356 fn auth_methods(&self) -> &[acp::AuthMethod] {
4357 &[]
4358 }
4359
4360 fn authenticate(
4361 &self,
4362 _method_id: acp::AuthMethodId,
4363 _cx: &mut App,
4364 ) -> Task<anyhow::Result<()>> {
4365 Task::ready(Ok(()))
4366 }
4367
4368 fn prompt(
4369 &self,
4370 _id: acp_thread::UserMessageId,
4371 _params: acp::PromptRequest,
4372 _cx: &mut App,
4373 ) -> Task<anyhow::Result<acp::PromptResponse>> {
4374 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4375 }
4376
4377 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4378
4379 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4380 self
4381 }
4382 }
4383
4384 #[derive(Clone)]
4385 struct ResumeOnlyAgentConnection;
4386
4387 impl AgentConnection for ResumeOnlyAgentConnection {
4388 fn agent_id(&self) -> AgentId {
4389 AgentId::new("resume-only")
4390 }
4391
4392 fn telemetry_id(&self) -> SharedString {
4393 "resume-only".into()
4394 }
4395
4396 fn new_session(
4397 self: Rc<Self>,
4398 project: Entity<Project>,
4399 _work_dirs: PathList,
4400 cx: &mut gpui::App,
4401 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4402 let thread = build_test_thread(
4403 self,
4404 project,
4405 "ResumeOnlyAgentConnection",
4406 SessionId::new("new-session"),
4407 cx,
4408 );
4409 Task::ready(Ok(thread))
4410 }
4411
4412 fn supports_resume_session(&self) -> bool {
4413 true
4414 }
4415
4416 fn resume_session(
4417 self: Rc<Self>,
4418 session_id: acp::SessionId,
4419 project: Entity<Project>,
4420 _work_dirs: PathList,
4421 _title: Option<SharedString>,
4422 cx: &mut App,
4423 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4424 let thread =
4425 build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
4426 Task::ready(Ok(thread))
4427 }
4428
4429 fn auth_methods(&self) -> &[acp::AuthMethod] {
4430 &[]
4431 }
4432
4433 fn authenticate(
4434 &self,
4435 _method_id: acp::AuthMethodId,
4436 _cx: &mut App,
4437 ) -> Task<gpui::Result<()>> {
4438 Task::ready(Ok(()))
4439 }
4440
4441 fn prompt(
4442 &self,
4443 _id: acp_thread::UserMessageId,
4444 _params: acp::PromptRequest,
4445 _cx: &mut App,
4446 ) -> Task<gpui::Result<acp::PromptResponse>> {
4447 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4448 }
4449
4450 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4451
4452 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4453 self
4454 }
4455 }
4456
4457 /// Simulates an agent that requires authentication before a session can be
4458 /// created. `new_session` returns `AuthRequired` until `authenticate` is
4459 /// called with the correct method, after which sessions are created normally.
4460 #[derive(Clone)]
4461 struct AuthGatedAgentConnection {
4462 authenticated: Arc<Mutex<bool>>,
4463 auth_method: acp::AuthMethod,
4464 }
4465
4466 impl AuthGatedAgentConnection {
4467 const AUTH_METHOD_ID: &str = "test-login";
4468
4469 fn new() -> Self {
4470 Self {
4471 authenticated: Arc::new(Mutex::new(false)),
4472 auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
4473 Self::AUTH_METHOD_ID,
4474 "Test Login",
4475 )),
4476 }
4477 }
4478 }
4479
4480 impl AgentConnection for AuthGatedAgentConnection {
4481 fn agent_id(&self) -> AgentId {
4482 AgentId::new("auth-gated")
4483 }
4484
4485 fn telemetry_id(&self) -> SharedString {
4486 "auth-gated".into()
4487 }
4488
4489 fn new_session(
4490 self: Rc<Self>,
4491 project: Entity<Project>,
4492 work_dirs: PathList,
4493 cx: &mut gpui::App,
4494 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4495 if !*self.authenticated.lock() {
4496 return Task::ready(Err(acp_thread::AuthRequired::new()
4497 .with_description("Sign in to continue".to_string())
4498 .into()));
4499 }
4500
4501 let session_id = acp::SessionId::new("auth-gated-session");
4502 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4503 Task::ready(Ok(cx.new(|cx| {
4504 AcpThread::new(
4505 None,
4506 None,
4507 Some(work_dirs),
4508 self,
4509 project,
4510 action_log,
4511 session_id,
4512 watch::Receiver::constant(
4513 acp::PromptCapabilities::new()
4514 .image(true)
4515 .audio(true)
4516 .embedded_context(true),
4517 ),
4518 cx,
4519 )
4520 })))
4521 }
4522
4523 fn auth_methods(&self) -> &[acp::AuthMethod] {
4524 std::slice::from_ref(&self.auth_method)
4525 }
4526
4527 fn authenticate(
4528 &self,
4529 method_id: acp::AuthMethodId,
4530 _cx: &mut App,
4531 ) -> Task<gpui::Result<()>> {
4532 if &method_id == self.auth_method.id() {
4533 *self.authenticated.lock() = true;
4534 Task::ready(Ok(()))
4535 } else {
4536 Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
4537 }
4538 }
4539
4540 fn prompt(
4541 &self,
4542 _id: acp_thread::UserMessageId,
4543 _params: acp::PromptRequest,
4544 _cx: &mut App,
4545 ) -> Task<gpui::Result<acp::PromptResponse>> {
4546 unimplemented!()
4547 }
4548
4549 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4550 unimplemented!()
4551 }
4552
4553 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4554 self
4555 }
4556 }
4557
4558 /// Simulates a model which always returns a refusal response
4559 #[derive(Clone)]
4560 struct RefusalAgentConnection;
4561
4562 impl AgentConnection for RefusalAgentConnection {
4563 fn agent_id(&self) -> AgentId {
4564 AgentId::new("refusal")
4565 }
4566
4567 fn telemetry_id(&self) -> SharedString {
4568 "refusal".into()
4569 }
4570
4571 fn new_session(
4572 self: Rc<Self>,
4573 project: Entity<Project>,
4574 work_dirs: PathList,
4575 cx: &mut gpui::App,
4576 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4577 Task::ready(Ok(cx.new(|cx| {
4578 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4579 AcpThread::new(
4580 None,
4581 None,
4582 Some(work_dirs),
4583 self,
4584 project,
4585 action_log,
4586 SessionId::new("test"),
4587 watch::Receiver::constant(
4588 acp::PromptCapabilities::new()
4589 .image(true)
4590 .audio(true)
4591 .embedded_context(true),
4592 ),
4593 cx,
4594 )
4595 })))
4596 }
4597
4598 fn auth_methods(&self) -> &[acp::AuthMethod] {
4599 &[]
4600 }
4601
4602 fn authenticate(
4603 &self,
4604 _method_id: acp::AuthMethodId,
4605 _cx: &mut App,
4606 ) -> Task<gpui::Result<()>> {
4607 unimplemented!()
4608 }
4609
4610 fn prompt(
4611 &self,
4612 _id: acp_thread::UserMessageId,
4613 _params: acp::PromptRequest,
4614 _cx: &mut App,
4615 ) -> Task<gpui::Result<acp::PromptResponse>> {
4616 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4617 }
4618
4619 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4620 unimplemented!()
4621 }
4622
4623 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4624 self
4625 }
4626 }
4627
4628 #[derive(Clone)]
4629 struct CwdCapturingConnection {
4630 captured_work_dirs: Arc<Mutex<Option<PathList>>>,
4631 }
4632
4633 impl CwdCapturingConnection {
4634 fn new() -> Self {
4635 Self {
4636 captured_work_dirs: Arc::new(Mutex::new(None)),
4637 }
4638 }
4639 }
4640
4641 impl AgentConnection for CwdCapturingConnection {
4642 fn agent_id(&self) -> AgentId {
4643 AgentId::new("cwd-capturing")
4644 }
4645
4646 fn telemetry_id(&self) -> SharedString {
4647 "cwd-capturing".into()
4648 }
4649
4650 fn new_session(
4651 self: Rc<Self>,
4652 project: Entity<Project>,
4653 work_dirs: PathList,
4654 cx: &mut gpui::App,
4655 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4656 *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4657 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4658 let thread = cx.new(|cx| {
4659 AcpThread::new(
4660 None,
4661 None,
4662 Some(work_dirs),
4663 self.clone(),
4664 project,
4665 action_log,
4666 SessionId::new("new-session"),
4667 watch::Receiver::constant(
4668 acp::PromptCapabilities::new()
4669 .image(true)
4670 .audio(true)
4671 .embedded_context(true),
4672 ),
4673 cx,
4674 )
4675 });
4676 Task::ready(Ok(thread))
4677 }
4678
4679 fn supports_load_session(&self) -> bool {
4680 true
4681 }
4682
4683 fn load_session(
4684 self: Rc<Self>,
4685 session_id: acp::SessionId,
4686 project: Entity<Project>,
4687 work_dirs: PathList,
4688 _title: Option<SharedString>,
4689 cx: &mut App,
4690 ) -> Task<gpui::Result<Entity<AcpThread>>> {
4691 *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4692 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4693 let thread = cx.new(|cx| {
4694 AcpThread::new(
4695 None,
4696 None,
4697 Some(work_dirs),
4698 self.clone(),
4699 project,
4700 action_log,
4701 session_id,
4702 watch::Receiver::constant(
4703 acp::PromptCapabilities::new()
4704 .image(true)
4705 .audio(true)
4706 .embedded_context(true),
4707 ),
4708 cx,
4709 )
4710 });
4711 Task::ready(Ok(thread))
4712 }
4713
4714 fn auth_methods(&self) -> &[acp::AuthMethod] {
4715 &[]
4716 }
4717
4718 fn authenticate(
4719 &self,
4720 _method_id: acp::AuthMethodId,
4721 _cx: &mut App,
4722 ) -> Task<gpui::Result<()>> {
4723 Task::ready(Ok(()))
4724 }
4725
4726 fn prompt(
4727 &self,
4728 _id: acp_thread::UserMessageId,
4729 _params: acp::PromptRequest,
4730 _cx: &mut App,
4731 ) -> Task<gpui::Result<acp::PromptResponse>> {
4732 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4733 }
4734
4735 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4736
4737 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4738 self
4739 }
4740 }
4741
4742 pub(crate) fn init_test(cx: &mut TestAppContext) {
4743 cx.update(|cx| {
4744 let settings_store = SettingsStore::test(cx);
4745 cx.set_global(settings_store);
4746 ThreadMetadataStore::init_global(cx);
4747 theme_settings::init(theme::LoadThemes::JustBase, cx);
4748 editor::init(cx);
4749 agent_panel::init(cx);
4750 release_channel::init(semver::Version::new(0, 0, 0), cx);
4751 prompt_store::init(cx)
4752 });
4753 }
4754
4755 fn active_thread(
4756 conversation_view: &Entity<ConversationView>,
4757 cx: &TestAppContext,
4758 ) -> Entity<ThreadView> {
4759 cx.read(|cx| {
4760 conversation_view
4761 .read(cx)
4762 .active_thread()
4763 .expect("No active thread")
4764 .clone()
4765 })
4766 }
4767
4768 fn message_editor(
4769 conversation_view: &Entity<ConversationView>,
4770 cx: &TestAppContext,
4771 ) -> Entity<MessageEditor> {
4772 let thread = active_thread(conversation_view, cx);
4773 cx.read(|cx| thread.read(cx).message_editor.clone())
4774 }
4775
4776 #[gpui::test]
4777 async fn test_rewind_views(cx: &mut TestAppContext) {
4778 init_test(cx);
4779
4780 let fs = FakeFs::new(cx.executor());
4781 fs.insert_tree(
4782 "/project",
4783 json!({
4784 "test1.txt": "old content 1",
4785 "test2.txt": "old content 2"
4786 }),
4787 )
4788 .await;
4789 let project = Project::test(fs, [Path::new("/project")], cx).await;
4790 let (multi_workspace, cx) =
4791 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4792 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4793
4794 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4795 let connection_store =
4796 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4797
4798 let connection = Rc::new(StubAgentConnection::new());
4799 let conversation_view = cx.update(|window, cx| {
4800 cx.new(|cx| {
4801 ConversationView::new(
4802 Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4803 connection_store,
4804 Agent::Custom { id: "Test".into() },
4805 None,
4806 None,
4807 None,
4808 None,
4809 None,
4810 workspace.downgrade(),
4811 project.clone(),
4812 Some(thread_store.clone()),
4813 None,
4814 "agent_panel",
4815 window,
4816 cx,
4817 )
4818 })
4819 });
4820
4821 cx.run_until_parked();
4822
4823 let thread = conversation_view
4824 .read_with(cx, |view, cx| {
4825 view.active_thread().map(|r| r.read(cx).thread.clone())
4826 })
4827 .unwrap();
4828
4829 // First user message
4830 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4831 acp::ToolCall::new("tool1", "Edit file 1")
4832 .kind(acp::ToolKind::Edit)
4833 .status(acp::ToolCallStatus::Completed)
4834 .content(vec![acp::ToolCallContent::Diff(
4835 acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4836 )]),
4837 )]);
4838
4839 thread
4840 .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4841 .await
4842 .unwrap();
4843 cx.run_until_parked();
4844
4845 thread.read_with(cx, |thread, _cx| {
4846 assert_eq!(thread.entries().len(), 2);
4847 });
4848
4849 conversation_view.read_with(cx, |view, cx| {
4850 let entry_view_state = view
4851 .active_thread()
4852 .map(|active| active.read(cx).entry_view_state.clone())
4853 .unwrap();
4854 entry_view_state.read_with(cx, |entry_view_state, _| {
4855 assert!(
4856 entry_view_state
4857 .entry(0)
4858 .unwrap()
4859 .message_editor()
4860 .is_some()
4861 );
4862 assert!(entry_view_state.entry(1).unwrap().has_content());
4863 });
4864 });
4865
4866 // Second user message
4867 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4868 acp::ToolCall::new("tool2", "Edit file 2")
4869 .kind(acp::ToolKind::Edit)
4870 .status(acp::ToolCallStatus::Completed)
4871 .content(vec![acp::ToolCallContent::Diff(
4872 acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
4873 )]),
4874 )]);
4875
4876 thread
4877 .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4878 .await
4879 .unwrap();
4880 cx.run_until_parked();
4881
4882 let second_user_message_id = thread.read_with(cx, |thread, _| {
4883 assert_eq!(thread.entries().len(), 4);
4884 let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4885 panic!();
4886 };
4887 user_message.id.clone().unwrap()
4888 });
4889
4890 conversation_view.read_with(cx, |view, cx| {
4891 let entry_view_state = view
4892 .active_thread()
4893 .unwrap()
4894 .read(cx)
4895 .entry_view_state
4896 .clone();
4897 entry_view_state.read_with(cx, |entry_view_state, _| {
4898 assert!(
4899 entry_view_state
4900 .entry(0)
4901 .unwrap()
4902 .message_editor()
4903 .is_some()
4904 );
4905 assert!(entry_view_state.entry(1).unwrap().has_content());
4906 assert!(
4907 entry_view_state
4908 .entry(2)
4909 .unwrap()
4910 .message_editor()
4911 .is_some()
4912 );
4913 assert!(entry_view_state.entry(3).unwrap().has_content());
4914 });
4915 });
4916
4917 // Rewind to first message
4918 thread
4919 .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4920 .await
4921 .unwrap();
4922
4923 cx.run_until_parked();
4924
4925 thread.read_with(cx, |thread, _| {
4926 assert_eq!(thread.entries().len(), 2);
4927 });
4928
4929 conversation_view.read_with(cx, |view, cx| {
4930 let active = view.active_thread().unwrap();
4931 active
4932 .read(cx)
4933 .entry_view_state
4934 .read_with(cx, |entry_view_state, _| {
4935 assert!(
4936 entry_view_state
4937 .entry(0)
4938 .unwrap()
4939 .message_editor()
4940 .is_some()
4941 );
4942 assert!(entry_view_state.entry(1).unwrap().has_content());
4943
4944 // Old views should be dropped
4945 assert!(entry_view_state.entry(2).is_none());
4946 assert!(entry_view_state.entry(3).is_none());
4947 });
4948 });
4949 }
4950
4951 #[gpui::test]
4952 async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
4953 init_test(cx);
4954
4955 let connection = StubAgentConnection::new();
4956
4957 // Each user prompt will result in a user message entry plus an agent message entry.
4958 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4959 acp::ContentChunk::new("Response 1".into()),
4960 )]);
4961
4962 let (conversation_view, cx) =
4963 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4964
4965 let thread = conversation_view
4966 .read_with(cx, |view, cx| {
4967 view.active_thread().map(|r| r.read(cx).thread.clone())
4968 })
4969 .unwrap();
4970
4971 thread
4972 .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
4973 .await
4974 .unwrap();
4975 cx.run_until_parked();
4976
4977 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4978 acp::ContentChunk::new("Response 2".into()),
4979 )]);
4980
4981 thread
4982 .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
4983 .await
4984 .unwrap();
4985 cx.run_until_parked();
4986
4987 // Move somewhere else first so we're not trivially already on the last user prompt.
4988 active_thread(&conversation_view, cx).update(cx, |view, cx| {
4989 view.scroll_to_top(cx);
4990 });
4991 cx.run_until_parked();
4992
4993 active_thread(&conversation_view, cx).update(cx, |view, cx| {
4994 view.scroll_to_most_recent_user_prompt(cx);
4995 let scroll_top = view.list_state.logical_scroll_top();
4996 // Entries layout is: [User1, Assistant1, User2, Assistant2]
4997 assert_eq!(scroll_top.item_ix, 2);
4998 });
4999 }
5000
5001 #[gpui::test]
5002 async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
5003 cx: &mut TestAppContext,
5004 ) {
5005 init_test(cx);
5006
5007 let (conversation_view, cx) =
5008 setup_conversation_view(StubAgentServer::default_response(), cx).await;
5009
5010 // With no entries, scrolling should be a no-op and must not panic.
5011 active_thread(&conversation_view, cx).update(cx, |view, cx| {
5012 view.scroll_to_most_recent_user_prompt(cx);
5013 let scroll_top = view.list_state.logical_scroll_top();
5014 assert_eq!(scroll_top.item_ix, 0);
5015 });
5016 }
5017
5018 #[gpui::test]
5019 async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5020 init_test(cx);
5021
5022 let connection = StubAgentConnection::new();
5023
5024 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5025 acp::ContentChunk::new("Response".into()),
5026 )]);
5027
5028 let (conversation_view, cx) =
5029 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5030 add_to_workspace(conversation_view.clone(), cx);
5031
5032 let message_editor = message_editor(&conversation_view, cx);
5033 message_editor.update_in(cx, |editor, window, cx| {
5034 editor.set_text("Original message to edit", window, cx);
5035 });
5036 active_thread(&conversation_view, cx)
5037 .update_in(cx, |view, window, cx| view.send(window, cx));
5038
5039 cx.run_until_parked();
5040
5041 let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5042 assert_eq!(
5043 view.active_thread()
5044 .and_then(|active| active.read(cx).editing_message),
5045 None
5046 );
5047
5048 view.active_thread()
5049 .map(|active| &active.read(cx).entry_view_state)
5050 .as_ref()
5051 .unwrap()
5052 .read(cx)
5053 .entry(0)
5054 .unwrap()
5055 .message_editor()
5056 .unwrap()
5057 .clone()
5058 });
5059
5060 // Focus
5061 cx.focus(&user_message_editor);
5062 conversation_view.read_with(cx, |view, cx| {
5063 assert_eq!(
5064 view.active_thread()
5065 .and_then(|active| active.read(cx).editing_message),
5066 Some(0)
5067 );
5068 });
5069
5070 // Edit
5071 user_message_editor.update_in(cx, |editor, window, cx| {
5072 editor.set_text("Edited message content", window, cx);
5073 });
5074
5075 // Cancel
5076 user_message_editor.update_in(cx, |_editor, window, cx| {
5077 window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5078 });
5079
5080 conversation_view.read_with(cx, |view, cx| {
5081 assert_eq!(
5082 view.active_thread()
5083 .and_then(|active| active.read(cx).editing_message),
5084 None
5085 );
5086 });
5087
5088 user_message_editor.read_with(cx, |editor, cx| {
5089 assert_eq!(editor.text(cx), "Original message to edit");
5090 });
5091 }
5092
5093 #[gpui::test]
5094 async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5095 init_test(cx);
5096
5097 let connection = StubAgentConnection::new();
5098
5099 let (conversation_view, cx) =
5100 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5101 add_to_workspace(conversation_view.clone(), cx);
5102
5103 let message_editor = message_editor(&conversation_view, cx);
5104 message_editor.update_in(cx, |editor, window, cx| {
5105 editor.set_text("", window, cx);
5106 });
5107
5108 let thread = cx.read(|cx| {
5109 conversation_view
5110 .read(cx)
5111 .active_thread()
5112 .unwrap()
5113 .read(cx)
5114 .thread
5115 .clone()
5116 });
5117 let entries_before = cx.read(|cx| thread.read(cx).entries().len());
5118
5119 active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
5120 view.send(window, cx);
5121 });
5122 cx.run_until_parked();
5123
5124 let entries_after = cx.read(|cx| thread.read(cx).entries().len());
5125 assert_eq!(
5126 entries_before, entries_after,
5127 "No message should be sent when editor is empty"
5128 );
5129 }
5130
5131 #[gpui::test]
5132 async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5133 init_test(cx);
5134
5135 let connection = StubAgentConnection::new();
5136
5137 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5138 acp::ContentChunk::new("Response".into()),
5139 )]);
5140
5141 let (conversation_view, cx) =
5142 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5143 add_to_workspace(conversation_view.clone(), cx);
5144
5145 let message_editor = message_editor(&conversation_view, cx);
5146 message_editor.update_in(cx, |editor, window, cx| {
5147 editor.set_text("Original message to edit", window, cx);
5148 });
5149 active_thread(&conversation_view, cx)
5150 .update_in(cx, |view, window, cx| view.send(window, cx));
5151
5152 cx.run_until_parked();
5153
5154 let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5155 assert_eq!(
5156 view.active_thread()
5157 .and_then(|active| active.read(cx).editing_message),
5158 None
5159 );
5160 assert_eq!(
5161 view.active_thread()
5162 .unwrap()
5163 .read(cx)
5164 .thread
5165 .read(cx)
5166 .entries()
5167 .len(),
5168 2
5169 );
5170
5171 view.active_thread()
5172 .map(|active| &active.read(cx).entry_view_state)
5173 .as_ref()
5174 .unwrap()
5175 .read(cx)
5176 .entry(0)
5177 .unwrap()
5178 .message_editor()
5179 .unwrap()
5180 .clone()
5181 });
5182
5183 // Focus
5184 cx.focus(&user_message_editor);
5185
5186 // Edit
5187 user_message_editor.update_in(cx, |editor, window, cx| {
5188 editor.set_text("Edited message content", window, cx);
5189 });
5190
5191 // Send
5192 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5193 acp::ContentChunk::new("New Response".into()),
5194 )]);
5195
5196 user_message_editor.update_in(cx, |_editor, window, cx| {
5197 window.dispatch_action(Box::new(Chat), cx);
5198 });
5199
5200 cx.run_until_parked();
5201
5202 conversation_view.read_with(cx, |view, cx| {
5203 assert_eq!(
5204 view.active_thread()
5205 .and_then(|active| active.read(cx).editing_message),
5206 None
5207 );
5208
5209 let entries = view
5210 .active_thread()
5211 .unwrap()
5212 .read(cx)
5213 .thread
5214 .read(cx)
5215 .entries();
5216 assert_eq!(entries.len(), 2);
5217 assert_eq!(
5218 entries[0].to_markdown(cx),
5219 "## User\n\nEdited message content\n\n"
5220 );
5221 assert_eq!(
5222 entries[1].to_markdown(cx),
5223 "## Assistant\n\nNew Response\n\n"
5224 );
5225
5226 let entry_view_state = view
5227 .active_thread()
5228 .map(|active| &active.read(cx).entry_view_state)
5229 .unwrap();
5230 let new_editor = entry_view_state.read_with(cx, |state, _cx| {
5231 assert!(!state.entry(1).unwrap().has_content());
5232 state.entry(0).unwrap().message_editor().unwrap().clone()
5233 });
5234
5235 assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5236 })
5237 }
5238
5239 #[gpui::test]
5240 async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5241 init_test(cx);
5242
5243 let connection = StubAgentConnection::new();
5244
5245 let (conversation_view, cx) =
5246 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5247 add_to_workspace(conversation_view.clone(), cx);
5248
5249 let message_editor = message_editor(&conversation_view, cx);
5250 message_editor.update_in(cx, |editor, window, cx| {
5251 editor.set_text("Original message to edit", window, cx);
5252 });
5253 active_thread(&conversation_view, cx)
5254 .update_in(cx, |view, window, cx| view.send(window, cx));
5255
5256 cx.run_until_parked();
5257
5258 let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
5259 let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
5260 assert_eq!(thread.entries().len(), 1);
5261
5262 let editor = view
5263 .active_thread()
5264 .map(|active| &active.read(cx).entry_view_state)
5265 .as_ref()
5266 .unwrap()
5267 .read(cx)
5268 .entry(0)
5269 .unwrap()
5270 .message_editor()
5271 .unwrap()
5272 .clone();
5273
5274 (editor, thread.session_id().clone())
5275 });
5276
5277 // Focus
5278 cx.focus(&user_message_editor);
5279
5280 conversation_view.read_with(cx, |view, cx| {
5281 assert_eq!(
5282 view.active_thread()
5283 .and_then(|active| active.read(cx).editing_message),
5284 Some(0)
5285 );
5286 });
5287
5288 // Edit
5289 user_message_editor.update_in(cx, |editor, window, cx| {
5290 editor.set_text("Edited message content", window, cx);
5291 });
5292
5293 conversation_view.read_with(cx, |view, cx| {
5294 assert_eq!(
5295 view.active_thread()
5296 .and_then(|active| active.read(cx).editing_message),
5297 Some(0)
5298 );
5299 });
5300
5301 // Finish streaming response
5302 cx.update(|_, cx| {
5303 connection.send_update(
5304 session_id.clone(),
5305 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
5306 cx,
5307 );
5308 connection.end_turn(session_id, acp::StopReason::EndTurn);
5309 });
5310
5311 conversation_view.read_with(cx, |view, cx| {
5312 assert_eq!(
5313 view.active_thread()
5314 .and_then(|active| active.read(cx).editing_message),
5315 Some(0)
5316 );
5317 });
5318
5319 cx.run_until_parked();
5320
5321 // Should still be editing
5322 cx.update(|window, cx| {
5323 assert!(user_message_editor.focus_handle(cx).is_focused(window));
5324 assert_eq!(
5325 conversation_view
5326 .read(cx)
5327 .active_thread()
5328 .and_then(|active| active.read(cx).editing_message),
5329 Some(0)
5330 );
5331 assert_eq!(
5332 user_message_editor.read(cx).text(cx),
5333 "Edited message content"
5334 );
5335 });
5336 }
5337
5338 #[gpui::test]
5339 async fn test_stale_stop_does_not_disable_follow_tail_during_regenerate(
5340 cx: &mut TestAppContext,
5341 ) {
5342 init_test(cx);
5343
5344 let connection = StubAgentConnection::new();
5345
5346 let (conversation_view, cx) =
5347 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5348 add_to_workspace(conversation_view.clone(), cx);
5349
5350 let message_editor = message_editor(&conversation_view, cx);
5351 message_editor.update_in(cx, |editor, window, cx| {
5352 editor.set_text("Original message to edit", window, cx);
5353 });
5354 active_thread(&conversation_view, cx)
5355 .update_in(cx, |view, window, cx| view.send(window, cx));
5356
5357 cx.run_until_parked();
5358
5359 let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5360 view.active_thread()
5361 .map(|active| &active.read(cx).entry_view_state)
5362 .as_ref()
5363 .unwrap()
5364 .read(cx)
5365 .entry(0)
5366 .unwrap()
5367 .message_editor()
5368 .unwrap()
5369 .clone()
5370 });
5371
5372 cx.focus(&user_message_editor);
5373 user_message_editor.update_in(cx, |editor, window, cx| {
5374 editor.set_text("Edited message content", window, cx);
5375 });
5376
5377 user_message_editor.update_in(cx, |_editor, window, cx| {
5378 window.dispatch_action(Box::new(Chat), cx);
5379 });
5380
5381 cx.run_until_parked();
5382
5383 conversation_view.read_with(cx, |view, cx| {
5384 let active = view.active_thread().unwrap();
5385 let active = active.read(cx);
5386
5387 assert_eq!(active.thread.read(cx).status(), ThreadStatus::Generating);
5388 assert!(
5389 active.list_state.is_following_tail(),
5390 "stale stop events from the cancelled turn must not disable follow-tail for the new turn"
5391 );
5392 });
5393 }
5394
5395 struct GeneratingThreadSetup {
5396 conversation_view: Entity<ConversationView>,
5397 thread: Entity<AcpThread>,
5398 message_editor: Entity<MessageEditor>,
5399 }
5400
5401 async fn setup_generating_thread(
5402 cx: &mut TestAppContext,
5403 ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
5404 let connection = StubAgentConnection::new();
5405
5406 let (conversation_view, cx) =
5407 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5408 add_to_workspace(conversation_view.clone(), cx);
5409
5410 let message_editor = message_editor(&conversation_view, cx);
5411 message_editor.update_in(cx, |editor, window, cx| {
5412 editor.set_text("Hello", window, cx);
5413 });
5414 active_thread(&conversation_view, cx)
5415 .update_in(cx, |view, window, cx| view.send(window, cx));
5416
5417 let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5418 let thread = view
5419 .active_thread()
5420 .as_ref()
5421 .unwrap()
5422 .read(cx)
5423 .thread
5424 .clone();
5425 (thread.clone(), thread.read(cx).session_id().clone())
5426 });
5427
5428 cx.run_until_parked();
5429
5430 cx.update(|_, cx| {
5431 connection.send_update(
5432 session_id.clone(),
5433 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5434 "Response chunk".into(),
5435 )),
5436 cx,
5437 );
5438 });
5439
5440 cx.run_until_parked();
5441
5442 thread.read_with(cx, |thread, _cx| {
5443 assert_eq!(thread.status(), ThreadStatus::Generating);
5444 });
5445
5446 (
5447 GeneratingThreadSetup {
5448 conversation_view,
5449 thread,
5450 message_editor,
5451 },
5452 cx,
5453 )
5454 }
5455
5456 #[gpui::test]
5457 async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
5458 init_test(cx);
5459
5460 let (setup, cx) = setup_generating_thread(cx).await;
5461
5462 let focus_handle = setup
5463 .conversation_view
5464 .read_with(cx, |view, cx| view.focus_handle(cx));
5465 cx.update(|window, cx| {
5466 window.focus(&focus_handle, cx);
5467 });
5468
5469 setup.conversation_view.update_in(cx, |_, window, cx| {
5470 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5471 });
5472
5473 cx.run_until_parked();
5474
5475 setup.thread.read_with(cx, |thread, _cx| {
5476 assert_eq!(thread.status(), ThreadStatus::Idle);
5477 });
5478 }
5479
5480 #[gpui::test]
5481 async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
5482 init_test(cx);
5483
5484 let (setup, cx) = setup_generating_thread(cx).await;
5485
5486 let editor_focus_handle = setup
5487 .message_editor
5488 .read_with(cx, |editor, cx| editor.focus_handle(cx));
5489 cx.update(|window, cx| {
5490 window.focus(&editor_focus_handle, cx);
5491 });
5492
5493 setup.message_editor.update_in(cx, |_, window, cx| {
5494 window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
5495 });
5496
5497 cx.run_until_parked();
5498
5499 setup.thread.read_with(cx, |thread, _cx| {
5500 assert_eq!(thread.status(), ThreadStatus::Idle);
5501 });
5502 }
5503
5504 #[gpui::test]
5505 async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
5506 init_test(cx);
5507
5508 let (conversation_view, cx) =
5509 setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
5510 add_to_workspace(conversation_view.clone(), cx);
5511
5512 let thread = conversation_view.read_with(cx, |view, cx| {
5513 view.active_thread().unwrap().read(cx).thread.clone()
5514 });
5515
5516 thread.read_with(cx, |thread, _cx| {
5517 assert_eq!(thread.status(), ThreadStatus::Idle);
5518 });
5519
5520 let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
5521 cx.update(|window, cx| {
5522 window.focus(&focus_handle, cx);
5523 });
5524
5525 conversation_view.update_in(cx, |_, window, cx| {
5526 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5527 });
5528
5529 cx.run_until_parked();
5530
5531 thread.read_with(cx, |thread, _cx| {
5532 assert_eq!(thread.status(), ThreadStatus::Idle);
5533 });
5534 }
5535
5536 #[gpui::test]
5537 async fn test_interrupt(cx: &mut TestAppContext) {
5538 init_test(cx);
5539
5540 let connection = StubAgentConnection::new();
5541
5542 let (conversation_view, cx) =
5543 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5544 add_to_workspace(conversation_view.clone(), cx);
5545
5546 let message_editor = message_editor(&conversation_view, cx);
5547 message_editor.update_in(cx, |editor, window, cx| {
5548 editor.set_text("Message 1", window, cx);
5549 });
5550 active_thread(&conversation_view, cx)
5551 .update_in(cx, |view, window, cx| view.send(window, cx));
5552
5553 let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5554 let thread = view.active_thread().unwrap().read(cx).thread.clone();
5555
5556 (thread.clone(), thread.read(cx).session_id().clone())
5557 });
5558
5559 cx.run_until_parked();
5560
5561 cx.update(|_, cx| {
5562 connection.send_update(
5563 session_id.clone(),
5564 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5565 "Message 1 resp".into(),
5566 )),
5567 cx,
5568 );
5569 });
5570
5571 cx.run_until_parked();
5572
5573 thread.read_with(cx, |thread, cx| {
5574 assert_eq!(
5575 thread.to_markdown(cx),
5576 indoc::indoc! {"
5577 ## User
5578
5579 Message 1
5580
5581 ## Assistant
5582
5583 Message 1 resp
5584
5585 "}
5586 )
5587 });
5588
5589 message_editor.update_in(cx, |editor, window, cx| {
5590 editor.set_text("Message 2", window, cx);
5591 });
5592 active_thread(&conversation_view, cx)
5593 .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5594
5595 cx.update(|_, cx| {
5596 // Simulate a response sent after beginning to cancel
5597 connection.send_update(
5598 session_id.clone(),
5599 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5600 cx,
5601 );
5602 });
5603
5604 cx.run_until_parked();
5605
5606 // Last Message 1 response should appear before Message 2
5607 thread.read_with(cx, |thread, cx| {
5608 assert_eq!(
5609 thread.to_markdown(cx),
5610 indoc::indoc! {"
5611 ## User
5612
5613 Message 1
5614
5615 ## Assistant
5616
5617 Message 1 response
5618
5619 ## User
5620
5621 Message 2
5622
5623 "}
5624 )
5625 });
5626
5627 cx.update(|_, cx| {
5628 connection.send_update(
5629 session_id.clone(),
5630 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5631 "Message 2 response".into(),
5632 )),
5633 cx,
5634 );
5635 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5636 });
5637
5638 cx.run_until_parked();
5639
5640 thread.read_with(cx, |thread, cx| {
5641 assert_eq!(
5642 thread.to_markdown(cx),
5643 indoc::indoc! {"
5644 ## User
5645
5646 Message 1
5647
5648 ## Assistant
5649
5650 Message 1 response
5651
5652 ## User
5653
5654 Message 2
5655
5656 ## Assistant
5657
5658 Message 2 response
5659
5660 "}
5661 )
5662 });
5663 }
5664
5665 #[gpui::test]
5666 async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5667 init_test(cx);
5668
5669 let connection = StubAgentConnection::new();
5670 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5671 acp::ContentChunk::new("Response".into()),
5672 )]);
5673
5674 let (conversation_view, cx) =
5675 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5676 add_to_workspace(conversation_view.clone(), cx);
5677
5678 let message_editor = message_editor(&conversation_view, cx);
5679 message_editor.update_in(cx, |editor, window, cx| {
5680 editor.set_text("Original message to edit", window, cx)
5681 });
5682 active_thread(&conversation_view, cx)
5683 .update_in(cx, |view, window, cx| view.send(window, cx));
5684 cx.run_until_parked();
5685
5686 let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
5687 conversation_view
5688 .active_thread()
5689 .map(|active| &active.read(cx).entry_view_state)
5690 .as_ref()
5691 .unwrap()
5692 .read(cx)
5693 .entry(0)
5694 .expect("Should have at least one entry")
5695 .message_editor()
5696 .expect("Should have message editor")
5697 .clone()
5698 });
5699
5700 cx.focus(&user_message_editor);
5701 conversation_view.read_with(cx, |view, cx| {
5702 assert_eq!(
5703 view.active_thread()
5704 .and_then(|active| active.read(cx).editing_message),
5705 Some(0)
5706 );
5707 });
5708
5709 // Ensure to edit the focused message before proceeding otherwise, since
5710 // its content is not different from what was sent, focus will be lost.
5711 user_message_editor.update_in(cx, |editor, window, cx| {
5712 editor.set_text("Original message to edit with ", window, cx)
5713 });
5714
5715 // Create a simple buffer with some text so we can create a selection
5716 // that will then be added to the message being edited.
5717 let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5718 (
5719 conversation_view.workspace.clone(),
5720 conversation_view.project.clone(),
5721 )
5722 });
5723 let buffer = project.update(cx, |project, cx| {
5724 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5725 });
5726
5727 workspace
5728 .update_in(cx, |workspace, window, cx| {
5729 let editor = cx.new(|cx| {
5730 let mut editor =
5731 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5732
5733 editor.change_selections(Default::default(), window, cx, |selections| {
5734 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5735 });
5736
5737 editor
5738 });
5739 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5740 })
5741 .unwrap();
5742
5743 conversation_view.update_in(cx, |view, window, cx| {
5744 assert_eq!(
5745 view.active_thread()
5746 .and_then(|active| active.read(cx).editing_message),
5747 Some(0)
5748 );
5749 view.insert_selections(window, cx);
5750 });
5751
5752 user_message_editor.read_with(cx, |editor, cx| {
5753 let text = editor.editor().read(cx).text(cx);
5754 let expected_text = String::from("Original message to edit with selection ");
5755
5756 assert_eq!(text, expected_text);
5757 });
5758 }
5759
5760 #[gpui::test]
5761 async fn test_insert_selections(cx: &mut TestAppContext) {
5762 init_test(cx);
5763
5764 let connection = StubAgentConnection::new();
5765 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5766 acp::ContentChunk::new("Response".into()),
5767 )]);
5768
5769 let (conversation_view, cx) =
5770 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5771 add_to_workspace(conversation_view.clone(), cx);
5772
5773 let message_editor = message_editor(&conversation_view, cx);
5774 message_editor.update_in(cx, |editor, window, cx| {
5775 editor.set_text("Can you review this snippet ", window, cx)
5776 });
5777
5778 // Create a simple buffer with some text so we can create a selection
5779 // that will then be added to the message being edited.
5780 let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5781 (
5782 conversation_view.workspace.clone(),
5783 conversation_view.project.clone(),
5784 )
5785 });
5786 let buffer = project.update(cx, |project, cx| {
5787 project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5788 });
5789
5790 workspace
5791 .update_in(cx, |workspace, window, cx| {
5792 let editor = cx.new(|cx| {
5793 let mut editor =
5794 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5795
5796 editor.change_selections(Default::default(), window, cx, |selections| {
5797 selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5798 });
5799
5800 editor
5801 });
5802 workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5803 })
5804 .unwrap();
5805
5806 conversation_view.update_in(cx, |view, window, cx| {
5807 assert_eq!(
5808 view.active_thread()
5809 .and_then(|active| active.read(cx).editing_message),
5810 None
5811 );
5812 view.insert_selections(window, cx);
5813 });
5814
5815 message_editor.read_with(cx, |editor, cx| {
5816 let text = editor.text(cx);
5817 let expected_txt = String::from("Can you review this snippet selection ");
5818
5819 assert_eq!(text, expected_txt);
5820 })
5821 }
5822
5823 #[gpui::test]
5824 async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5825 init_test(cx);
5826
5827 let tool_call_id = acp::ToolCallId::new("terminal-1");
5828 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5829 .kind(acp::ToolKind::Edit);
5830
5831 let permission_options = ToolPermissionContext::new(
5832 TerminalTool::NAME,
5833 vec!["cargo build --release".to_string()],
5834 )
5835 .build_permission_options();
5836
5837 let connection =
5838 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5839 tool_call_id.clone(),
5840 permission_options,
5841 )]));
5842
5843 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5844
5845 let (conversation_view, cx) =
5846 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5847
5848 // Disable notifications to avoid popup windows
5849 cx.update(|_window, cx| {
5850 AgentSettings::override_global(
5851 AgentSettings {
5852 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5853 ..AgentSettings::get_global(cx).clone()
5854 },
5855 cx,
5856 );
5857 });
5858
5859 let message_editor = message_editor(&conversation_view, cx);
5860 message_editor.update_in(cx, |editor, window, cx| {
5861 editor.set_text("Run cargo build", window, cx);
5862 });
5863
5864 active_thread(&conversation_view, cx)
5865 .update_in(cx, |view, window, cx| view.send(window, cx));
5866
5867 cx.run_until_parked();
5868
5869 // Verify the tool call is in WaitingForConfirmation state with the expected options
5870 conversation_view.read_with(cx, |conversation_view, cx| {
5871 let thread = conversation_view
5872 .active_thread()
5873 .expect("Thread should exist")
5874 .read(cx)
5875 .thread
5876 .clone();
5877 let thread = thread.read(cx);
5878
5879 let tool_call = thread.entries().iter().find_map(|entry| {
5880 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5881 Some(call)
5882 } else {
5883 None
5884 }
5885 });
5886
5887 assert!(tool_call.is_some(), "Expected a tool call entry");
5888 let tool_call = tool_call.unwrap();
5889
5890 // Verify it's waiting for confirmation
5891 assert!(
5892 matches!(
5893 tool_call.status,
5894 acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
5895 ),
5896 "Expected WaitingForConfirmation status, got {:?}",
5897 tool_call.status
5898 );
5899
5900 // Verify the options count (granularity options only, no separate Deny option)
5901 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5902 &tool_call.status
5903 {
5904 let PermissionOptions::Dropdown(choices) = options else {
5905 panic!("Expected dropdown permission options");
5906 };
5907
5908 assert_eq!(
5909 choices.len(),
5910 3,
5911 "Expected 3 permission options (granularity only)"
5912 );
5913
5914 // Verify specific button labels (now using neutral names)
5915 let labels: Vec<&str> = choices
5916 .iter()
5917 .map(|choice| choice.allow.name.as_ref())
5918 .collect();
5919 assert!(
5920 labels.contains(&"Always for terminal"),
5921 "Missing 'Always for terminal' option"
5922 );
5923 assert!(
5924 labels.contains(&"Always for `cargo build` commands"),
5925 "Missing pattern option"
5926 );
5927 assert!(
5928 labels.contains(&"Only this time"),
5929 "Missing 'Only this time' option"
5930 );
5931 }
5932 });
5933 }
5934
5935 #[gpui::test]
5936 async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
5937 init_test(cx);
5938
5939 let tool_call_id = acp::ToolCallId::new("edit-file-1");
5940 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
5941 .kind(acp::ToolKind::Edit);
5942
5943 let permission_options =
5944 ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
5945 .build_permission_options();
5946
5947 let connection =
5948 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5949 tool_call_id.clone(),
5950 permission_options,
5951 )]));
5952
5953 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5954
5955 let (conversation_view, cx) =
5956 setup_conversation_view(StubAgentServer::new(connection), cx).await;
5957
5958 // Disable notifications
5959 cx.update(|_window, cx| {
5960 AgentSettings::override_global(
5961 AgentSettings {
5962 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5963 ..AgentSettings::get_global(cx).clone()
5964 },
5965 cx,
5966 );
5967 });
5968
5969 let message_editor = message_editor(&conversation_view, cx);
5970 message_editor.update_in(cx, |editor, window, cx| {
5971 editor.set_text("Edit the main file", window, cx);
5972 });
5973
5974 active_thread(&conversation_view, cx)
5975 .update_in(cx, |view, window, cx| view.send(window, cx));
5976
5977 cx.run_until_parked();
5978
5979 // Verify the options
5980 conversation_view.read_with(cx, |conversation_view, cx| {
5981 let thread = conversation_view
5982 .active_thread()
5983 .expect("Thread should exist")
5984 .read(cx)
5985 .thread
5986 .clone();
5987 let thread = thread.read(cx);
5988
5989 let tool_call = thread.entries().iter().find_map(|entry| {
5990 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5991 Some(call)
5992 } else {
5993 None
5994 }
5995 });
5996
5997 assert!(tool_call.is_some(), "Expected a tool call entry");
5998 let tool_call = tool_call.unwrap();
5999
6000 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6001 &tool_call.status
6002 {
6003 let PermissionOptions::Dropdown(choices) = options else {
6004 panic!("Expected dropdown permission options");
6005 };
6006
6007 let labels: Vec<&str> = choices
6008 .iter()
6009 .map(|choice| choice.allow.name.as_ref())
6010 .collect();
6011 assert!(
6012 labels.contains(&"Always for edit file"),
6013 "Missing 'Always for edit file' option"
6014 );
6015 assert!(
6016 labels.contains(&"Always for `src/`"),
6017 "Missing path pattern option"
6018 );
6019 } else {
6020 panic!("Expected WaitingForConfirmation status");
6021 }
6022 });
6023 }
6024
6025 #[gpui::test]
6026 async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
6027 init_test(cx);
6028
6029 let tool_call_id = acp::ToolCallId::new("fetch-1");
6030 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
6031 .kind(acp::ToolKind::Fetch);
6032
6033 let permission_options =
6034 ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
6035 .build_permission_options();
6036
6037 let connection =
6038 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6039 tool_call_id.clone(),
6040 permission_options,
6041 )]));
6042
6043 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6044
6045 let (conversation_view, cx) =
6046 setup_conversation_view(StubAgentServer::new(connection), cx).await;
6047
6048 // Disable notifications
6049 cx.update(|_window, cx| {
6050 AgentSettings::override_global(
6051 AgentSettings {
6052 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6053 ..AgentSettings::get_global(cx).clone()
6054 },
6055 cx,
6056 );
6057 });
6058
6059 let message_editor = message_editor(&conversation_view, cx);
6060 message_editor.update_in(cx, |editor, window, cx| {
6061 editor.set_text("Fetch the docs", window, cx);
6062 });
6063
6064 active_thread(&conversation_view, cx)
6065 .update_in(cx, |view, window, cx| view.send(window, cx));
6066
6067 cx.run_until_parked();
6068
6069 // Verify the options
6070 conversation_view.read_with(cx, |conversation_view, cx| {
6071 let thread = conversation_view
6072 .active_thread()
6073 .expect("Thread should exist")
6074 .read(cx)
6075 .thread
6076 .clone();
6077 let thread = thread.read(cx);
6078
6079 let tool_call = thread.entries().iter().find_map(|entry| {
6080 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6081 Some(call)
6082 } else {
6083 None
6084 }
6085 });
6086
6087 assert!(tool_call.is_some(), "Expected a tool call entry");
6088 let tool_call = tool_call.unwrap();
6089
6090 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6091 &tool_call.status
6092 {
6093 let PermissionOptions::Dropdown(choices) = options else {
6094 panic!("Expected dropdown permission options");
6095 };
6096
6097 let labels: Vec<&str> = choices
6098 .iter()
6099 .map(|choice| choice.allow.name.as_ref())
6100 .collect();
6101 assert!(
6102 labels.contains(&"Always for fetch"),
6103 "Missing 'Always for fetch' option"
6104 );
6105 assert!(
6106 labels.contains(&"Always for `docs.rs`"),
6107 "Missing domain pattern option"
6108 );
6109 } else {
6110 panic!("Expected WaitingForConfirmation status");
6111 }
6112 });
6113 }
6114
6115 #[gpui::test]
6116 async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
6117 init_test(cx);
6118
6119 let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
6120 let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
6121 .kind(acp::ToolKind::Edit);
6122
6123 // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
6124 let permission_options = ToolPermissionContext::new(
6125 TerminalTool::NAME,
6126 vec!["./deploy.sh --production".to_string()],
6127 )
6128 .build_permission_options();
6129
6130 let connection =
6131 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6132 tool_call_id.clone(),
6133 permission_options,
6134 )]));
6135
6136 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6137
6138 let (conversation_view, cx) =
6139 setup_conversation_view(StubAgentServer::new(connection), cx).await;
6140
6141 // Disable notifications
6142 cx.update(|_window, cx| {
6143 AgentSettings::override_global(
6144 AgentSettings {
6145 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6146 ..AgentSettings::get_global(cx).clone()
6147 },
6148 cx,
6149 );
6150 });
6151
6152 let message_editor = message_editor(&conversation_view, cx);
6153 message_editor.update_in(cx, |editor, window, cx| {
6154 editor.set_text("Run the deploy script", window, cx);
6155 });
6156
6157 active_thread(&conversation_view, cx)
6158 .update_in(cx, |view, window, cx| view.send(window, cx));
6159
6160 cx.run_until_parked();
6161
6162 // Verify only 2 options (no pattern button when command doesn't match pattern)
6163 conversation_view.read_with(cx, |conversation_view, cx| {
6164 let thread = conversation_view
6165 .active_thread()
6166 .expect("Thread should exist")
6167 .read(cx)
6168 .thread
6169 .clone();
6170 let thread = thread.read(cx);
6171
6172 let tool_call = thread.entries().iter().find_map(|entry| {
6173 if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6174 Some(call)
6175 } else {
6176 None
6177 }
6178 });
6179
6180 assert!(tool_call.is_some(), "Expected a tool call entry");
6181 let tool_call = tool_call.unwrap();
6182
6183 if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6184 &tool_call.status
6185 {
6186 let PermissionOptions::Dropdown(choices) = options else {
6187 panic!("Expected dropdown permission options");
6188 };
6189
6190 assert_eq!(
6191 choices.len(),
6192 2,
6193 "Expected 2 permission options (no pattern option)"
6194 );
6195
6196 let labels: Vec<&str> = choices
6197 .iter()
6198 .map(|choice| choice.allow.name.as_ref())
6199 .collect();
6200 assert!(
6201 labels.contains(&"Always for terminal"),
6202 "Missing 'Always for terminal' option"
6203 );
6204 assert!(
6205 labels.contains(&"Only this time"),
6206 "Missing 'Only this time' option"
6207 );
6208 // Should NOT contain a pattern option
6209 assert!(
6210 !labels.iter().any(|l| l.contains("commands")),
6211 "Should not have pattern option"
6212 );
6213 } else {
6214 panic!("Expected WaitingForConfirmation status");
6215 }
6216 });
6217 }
6218
6219 #[gpui::test]
6220 async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
6221 init_test(cx);
6222
6223 let tool_call_id = acp::ToolCallId::new("action-test-1");
6224 let tool_call =
6225 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
6226
6227 let permission_options =
6228 ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
6229 .build_permission_options();
6230
6231 let connection =
6232 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6233 tool_call_id.clone(),
6234 permission_options,
6235 )]));
6236
6237 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6238
6239 let (conversation_view, cx) =
6240 setup_conversation_view(StubAgentServer::new(connection), cx).await;
6241 add_to_workspace(conversation_view.clone(), cx);
6242
6243 cx.update(|_window, cx| {
6244 AgentSettings::override_global(
6245 AgentSettings {
6246 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6247 ..AgentSettings::get_global(cx).clone()
6248 },
6249 cx,
6250 );
6251 });
6252
6253 let message_editor = message_editor(&conversation_view, cx);
6254 message_editor.update_in(cx, |editor, window, cx| {
6255 editor.set_text("Run tests", window, cx);
6256 });
6257
6258 active_thread(&conversation_view, cx)
6259 .update_in(cx, |view, window, cx| view.send(window, cx));
6260
6261 cx.run_until_parked();
6262
6263 // Verify tool call is waiting for confirmation
6264 conversation_view.read_with(cx, |conversation_view, cx| {
6265 let tool_call = conversation_view.pending_tool_call(cx);
6266 assert!(
6267 tool_call.is_some(),
6268 "Expected a tool call waiting for confirmation"
6269 );
6270 });
6271
6272 // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
6273 conversation_view.update_in(cx, |_, window, cx| {
6274 window.dispatch_action(
6275 crate::AuthorizeToolCall {
6276 tool_call_id: "action-test-1".to_string(),
6277 option_id: "allow".to_string(),
6278 option_kind: "AllowOnce".to_string(),
6279 }
6280 .boxed_clone(),
6281 cx,
6282 );
6283 });
6284
6285 cx.run_until_parked();
6286
6287 // Verify tool call is no longer waiting for confirmation (was authorized)
6288 conversation_view.read_with(cx, |conversation_view, cx| {
6289 let tool_call = conversation_view.pending_tool_call(cx);
6290 assert!(
6291 tool_call.is_none(),
6292 "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
6293 );
6294 });
6295 }
6296
6297 #[gpui::test]
6298 async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
6299 init_test(cx);
6300
6301 let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
6302 let tool_call =
6303 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6304
6305 let permission_options =
6306 ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6307 .build_permission_options();
6308
6309 let connection =
6310 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6311 tool_call_id.clone(),
6312 permission_options.clone(),
6313 )]));
6314
6315 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6316
6317 let (conversation_view, cx) =
6318 setup_conversation_view(StubAgentServer::new(connection), cx).await;
6319 add_to_workspace(conversation_view.clone(), cx);
6320
6321 cx.update(|_window, cx| {
6322 AgentSettings::override_global(
6323 AgentSettings {
6324 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6325 ..AgentSettings::get_global(cx).clone()
6326 },
6327 cx,
6328 );
6329 });
6330
6331 let message_editor = message_editor(&conversation_view, cx);
6332 message_editor.update_in(cx, |editor, window, cx| {
6333 editor.set_text("Install dependencies", window, cx);
6334 });
6335
6336 active_thread(&conversation_view, cx)
6337 .update_in(cx, |view, window, cx| view.send(window, cx));
6338
6339 cx.run_until_parked();
6340
6341 // Find the pattern option ID (the choice with non-empty sub_patterns)
6342 let pattern_option = match &permission_options {
6343 PermissionOptions::Dropdown(choices) => choices
6344 .iter()
6345 .find(|choice| !choice.sub_patterns.is_empty())
6346 .map(|choice| &choice.allow)
6347 .expect("Should have a pattern option for npm command"),
6348 _ => panic!("Expected dropdown permission options"),
6349 };
6350
6351 // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
6352 conversation_view.update_in(cx, |_, window, cx| {
6353 window.dispatch_action(
6354 crate::AuthorizeToolCall {
6355 tool_call_id: "pattern-action-test-1".to_string(),
6356 option_id: pattern_option.option_id.0.to_string(),
6357 option_kind: "AllowAlways".to_string(),
6358 }
6359 .boxed_clone(),
6360 cx,
6361 );
6362 });
6363
6364 cx.run_until_parked();
6365
6366 // Verify tool call was authorized
6367 conversation_view.read_with(cx, |conversation_view, cx| {
6368 let tool_call = conversation_view.pending_tool_call(cx);
6369 assert!(
6370 tool_call.is_none(),
6371 "Tool call should be authorized after selecting pattern option"
6372 );
6373 });
6374 }
6375
6376 #[gpui::test]
6377 async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
6378 init_test(cx);
6379
6380 let tool_call_id = acp::ToolCallId::new("granularity-test-1");
6381 let tool_call =
6382 acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
6383
6384 let permission_options =
6385 ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6386 .build_permission_options();
6387
6388 let connection =
6389 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6390 tool_call_id.clone(),
6391 permission_options.clone(),
6392 )]));
6393
6394 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6395
6396 let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6397 add_to_workspace(thread_view.clone(), cx);
6398
6399 cx.update(|_window, cx| {
6400 AgentSettings::override_global(
6401 AgentSettings {
6402 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6403 ..AgentSettings::get_global(cx).clone()
6404 },
6405 cx,
6406 );
6407 });
6408
6409 let message_editor = message_editor(&thread_view, cx);
6410 message_editor.update_in(cx, |editor, window, cx| {
6411 editor.set_text("Build the project", window, cx);
6412 });
6413
6414 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6415
6416 cx.run_until_parked();
6417
6418 // Verify default granularity is the last option (index 2 = "Only this time")
6419 thread_view.read_with(cx, |thread_view, cx| {
6420 let state = thread_view.active_thread().unwrap();
6421 let selected = state.read(cx).permission_selections.get(&tool_call_id);
6422 assert!(
6423 selected.is_none(),
6424 "Should have no selection initially (defaults to last)"
6425 );
6426 });
6427
6428 // Select the first option (index 0 = "Always for terminal")
6429 thread_view.update_in(cx, |_, window, cx| {
6430 window.dispatch_action(
6431 crate::SelectPermissionGranularity {
6432 tool_call_id: "granularity-test-1".to_string(),
6433 index: 0,
6434 }
6435 .boxed_clone(),
6436 cx,
6437 );
6438 });
6439
6440 cx.run_until_parked();
6441
6442 // Verify the selection was updated
6443 thread_view.read_with(cx, |thread_view, cx| {
6444 let state = thread_view.active_thread().unwrap();
6445 let selected = state.read(cx).permission_selections.get(&tool_call_id);
6446 assert_eq!(
6447 selected.and_then(|s| s.choice_index()),
6448 Some(0),
6449 "Should have selected index 0"
6450 );
6451 });
6452 }
6453
6454 #[gpui::test]
6455 async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
6456 init_test(cx);
6457
6458 let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
6459 let tool_call =
6460 acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6461
6462 let permission_options =
6463 ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6464 .build_permission_options();
6465
6466 // Verify we have the expected options
6467 let PermissionOptions::Dropdown(choices) = &permission_options else {
6468 panic!("Expected dropdown permission options");
6469 };
6470
6471 assert_eq!(choices.len(), 3);
6472 assert!(
6473 choices[0]
6474 .allow
6475 .option_id
6476 .0
6477 .contains("always_allow:terminal")
6478 );
6479 assert!(
6480 choices[1]
6481 .allow
6482 .option_id
6483 .0
6484 .contains("always_allow:terminal")
6485 );
6486 assert!(!choices[1].sub_patterns.is_empty());
6487 assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
6488
6489 let connection =
6490 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6491 tool_call_id.clone(),
6492 permission_options.clone(),
6493 )]));
6494
6495 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6496
6497 let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6498 add_to_workspace(thread_view.clone(), cx);
6499
6500 cx.update(|_window, cx| {
6501 AgentSettings::override_global(
6502 AgentSettings {
6503 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6504 ..AgentSettings::get_global(cx).clone()
6505 },
6506 cx,
6507 );
6508 });
6509
6510 let message_editor = message_editor(&thread_view, cx);
6511 message_editor.update_in(cx, |editor, window, cx| {
6512 editor.set_text("Install dependencies", window, cx);
6513 });
6514
6515 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6516
6517 cx.run_until_parked();
6518
6519 // Select the pattern option (index 1 = "Always for `npm` commands")
6520 thread_view.update_in(cx, |_, window, cx| {
6521 window.dispatch_action(
6522 crate::SelectPermissionGranularity {
6523 tool_call_id: "allow-granularity-test-1".to_string(),
6524 index: 1,
6525 }
6526 .boxed_clone(),
6527 cx,
6528 );
6529 });
6530
6531 cx.run_until_parked();
6532
6533 // Simulate clicking the Allow button by dispatching AllowOnce action
6534 // which should use the selected granularity
6535 active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
6536 view.allow_once(&AllowOnce, window, cx)
6537 });
6538
6539 cx.run_until_parked();
6540
6541 // Verify tool call was authorized
6542 thread_view.read_with(cx, |thread_view, cx| {
6543 let tool_call = thread_view.pending_tool_call(cx);
6544 assert!(
6545 tool_call.is_none(),
6546 "Tool call should be authorized after Allow with pattern granularity"
6547 );
6548 });
6549 }
6550
6551 #[gpui::test]
6552 async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
6553 init_test(cx);
6554
6555 let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
6556 let tool_call =
6557 acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
6558
6559 let permission_options =
6560 ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
6561 .build_permission_options();
6562
6563 let connection =
6564 StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6565 tool_call_id.clone(),
6566 permission_options.clone(),
6567 )]));
6568
6569 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6570
6571 let (conversation_view, cx) =
6572 setup_conversation_view(StubAgentServer::new(connection), cx).await;
6573 add_to_workspace(conversation_view.clone(), cx);
6574
6575 cx.update(|_window, cx| {
6576 AgentSettings::override_global(
6577 AgentSettings {
6578 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6579 ..AgentSettings::get_global(cx).clone()
6580 },
6581 cx,
6582 );
6583 });
6584
6585 let message_editor = message_editor(&conversation_view, cx);
6586 message_editor.update_in(cx, |editor, window, cx| {
6587 editor.set_text("Push changes", window, cx);
6588 });
6589
6590 active_thread(&conversation_view, cx)
6591 .update_in(cx, |view, window, cx| view.send(window, cx));
6592
6593 cx.run_until_parked();
6594
6595 // Use default granularity (last option = "Only this time")
6596 // Simulate clicking the Deny button
6597 active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6598 view.reject_once(&RejectOnce, window, cx)
6599 });
6600
6601 cx.run_until_parked();
6602
6603 // Verify tool call was rejected (no longer waiting for confirmation)
6604 conversation_view.read_with(cx, |conversation_view, cx| {
6605 let tool_call = conversation_view.pending_tool_call(cx);
6606 assert!(
6607 tool_call.is_none(),
6608 "Tool call should be rejected after Deny"
6609 );
6610 });
6611 }
6612
6613 #[gpui::test]
6614 async fn test_option_id_transformation_for_allow() {
6615 let permission_options = ToolPermissionContext::new(
6616 TerminalTool::NAME,
6617 vec!["cargo build --release".to_string()],
6618 )
6619 .build_permission_options();
6620
6621 let PermissionOptions::Dropdown(choices) = permission_options else {
6622 panic!("Expected dropdown permission options");
6623 };
6624
6625 let allow_ids: Vec<String> = choices
6626 .iter()
6627 .map(|choice| choice.allow.option_id.0.to_string())
6628 .collect();
6629
6630 assert!(allow_ids.contains(&"allow".to_string()));
6631 assert_eq!(
6632 allow_ids
6633 .iter()
6634 .filter(|id| *id == "always_allow:terminal")
6635 .count(),
6636 2,
6637 "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6638 );
6639 }
6640
6641 #[gpui::test]
6642 async fn test_option_id_transformation_for_deny() {
6643 let permission_options = ToolPermissionContext::new(
6644 TerminalTool::NAME,
6645 vec!["cargo build --release".to_string()],
6646 )
6647 .build_permission_options();
6648
6649 let PermissionOptions::Dropdown(choices) = permission_options else {
6650 panic!("Expected dropdown permission options");
6651 };
6652
6653 let deny_ids: Vec<String> = choices
6654 .iter()
6655 .map(|choice| choice.deny.option_id.0.to_string())
6656 .collect();
6657
6658 assert!(deny_ids.contains(&"deny".to_string()));
6659 assert_eq!(
6660 deny_ids
6661 .iter()
6662 .filter(|id| *id == "always_deny:terminal")
6663 .count(),
6664 2,
6665 "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6666 );
6667 }
6668
6669 #[gpui::test]
6670 async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6671 init_test(cx);
6672
6673 let (conversation_view, cx) =
6674 setup_conversation_view(StubAgentServer::default_response(), cx).await;
6675 add_to_workspace(conversation_view.clone(), cx);
6676
6677 let active = active_thread(&conversation_view, cx);
6678 let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6679 let thread = cx.read(|cx| active.read(cx).thread.clone());
6680
6681 title_editor.read_with(cx, |editor, cx| {
6682 assert!(!editor.read_only(cx));
6683 });
6684
6685 cx.focus(&conversation_view);
6686 cx.focus(&title_editor);
6687
6688 cx.dispatch_action(editor::actions::DeleteLine);
6689 cx.simulate_input("My Custom Title");
6690
6691 cx.run_until_parked();
6692
6693 title_editor.read_with(cx, |editor, cx| {
6694 assert_eq!(editor.text(cx), "My Custom Title");
6695 });
6696 thread.read_with(cx, |thread, _cx| {
6697 assert_eq!(thread.title(), Some("My Custom Title".into()));
6698 });
6699 }
6700
6701 #[gpui::test]
6702 async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6703 init_test(cx);
6704
6705 let (conversation_view, cx) =
6706 setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6707
6708 let active = active_thread(&conversation_view, cx);
6709 let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6710
6711 title_editor.read_with(cx, |editor, cx| {
6712 assert!(
6713 editor.read_only(cx),
6714 "Title editor should be read-only when the connection does not support set_title"
6715 );
6716 });
6717 }
6718
6719 #[gpui::test]
6720 async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
6721 init_test(cx);
6722
6723 let connection = StubAgentConnection::new();
6724
6725 let (conversation_view, cx) =
6726 setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
6727
6728 let message_editor = message_editor(&conversation_view, cx);
6729 message_editor.update_in(cx, |editor, window, cx| {
6730 editor.set_text("Some prompt", window, cx);
6731 });
6732 active_thread(&conversation_view, cx)
6733 .update_in(cx, |view, window, cx| view.send(window, cx));
6734
6735 let session_id = conversation_view.read_with(cx, |view, cx| {
6736 view.active_thread()
6737 .unwrap()
6738 .read(cx)
6739 .thread
6740 .read(cx)
6741 .session_id()
6742 .clone()
6743 });
6744
6745 cx.run_until_parked();
6746
6747 cx.update(|_, _cx| {
6748 connection.end_turn(session_id, acp::StopReason::MaxTokens);
6749 });
6750
6751 cx.run_until_parked();
6752
6753 conversation_view.read_with(cx, |conversation_view, cx| {
6754 let state = conversation_view.active_thread().unwrap();
6755 let error = &state.read(cx).thread_error;
6756 assert!(
6757 matches!(error, Some(ThreadError::MaxOutputTokens)),
6758 "Expected ThreadError::MaxOutputTokens, got: {:?}",
6759 error.is_some()
6760 );
6761 });
6762 }
6763
6764 fn create_test_acp_thread(
6765 parent_session_id: Option<acp::SessionId>,
6766 session_id: &str,
6767 connection: Rc<dyn AgentConnection>,
6768 project: Entity<Project>,
6769 cx: &mut App,
6770 ) -> Entity<AcpThread> {
6771 let action_log = cx.new(|_| ActionLog::new(project.clone()));
6772 cx.new(|cx| {
6773 AcpThread::new(
6774 parent_session_id,
6775 None,
6776 None,
6777 connection,
6778 project,
6779 action_log,
6780 acp::SessionId::new(session_id),
6781 watch::Receiver::constant(acp::PromptCapabilities::new()),
6782 cx,
6783 )
6784 })
6785 }
6786
6787 fn request_test_tool_authorization(
6788 thread: &Entity<AcpThread>,
6789 tool_call_id: &str,
6790 option_id: &str,
6791 cx: &mut TestAppContext,
6792 ) -> Task<acp_thread::RequestPermissionOutcome> {
6793 let tool_call_id = acp::ToolCallId::new(tool_call_id);
6794 let label = format!("Tool {tool_call_id}");
6795 let option_id = acp::PermissionOptionId::new(option_id);
6796 cx.update(|cx| {
6797 thread.update(cx, |thread, cx| {
6798 thread
6799 .request_tool_call_authorization(
6800 acp::ToolCall::new(tool_call_id, label)
6801 .kind(acp::ToolKind::Edit)
6802 .into(),
6803 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6804 option_id,
6805 "Allow",
6806 acp::PermissionOptionKind::AllowOnce,
6807 )]),
6808 cx,
6809 )
6810 .unwrap()
6811 })
6812 })
6813 }
6814
6815 #[gpui::test]
6816 async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
6817 init_test(cx);
6818
6819 let fs = FakeFs::new(cx.executor());
6820 let project = Project::test(fs, [], cx).await;
6821 let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6822
6823 let session_id = acp::SessionId::new("session-1");
6824 let (thread, conversation) = cx.update(|cx| {
6825 let thread =
6826 create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
6827 let conversation = cx.new(|cx| {
6828 let mut conversation = Conversation::default();
6829 conversation.register_thread(thread.clone(), cx);
6830 conversation
6831 });
6832 (thread, conversation)
6833 });
6834
6835 let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
6836 let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
6837
6838 cx.read(|cx| {
6839 let (_, tool_call_id, _) = conversation
6840 .read(cx)
6841 .pending_tool_call(&session_id, cx)
6842 .expect("Expected a pending tool call");
6843 assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
6844 });
6845
6846 cx.update(|cx| {
6847 conversation.update(cx, |conversation, cx| {
6848 conversation.authorize_tool_call(
6849 session_id.clone(),
6850 acp::ToolCallId::new("tc-1"),
6851 SelectedPermissionOutcome::new(
6852 acp::PermissionOptionId::new("allow-1"),
6853 acp::PermissionOptionKind::AllowOnce,
6854 ),
6855 cx,
6856 );
6857 });
6858 });
6859
6860 cx.run_until_parked();
6861
6862 cx.read(|cx| {
6863 let (_, tool_call_id, _) = conversation
6864 .read(cx)
6865 .pending_tool_call(&session_id, cx)
6866 .expect("Expected tc-2 to be pending after tc-1 was authorized");
6867 assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
6868 });
6869
6870 cx.update(|cx| {
6871 conversation.update(cx, |conversation, cx| {
6872 conversation.authorize_tool_call(
6873 session_id.clone(),
6874 acp::ToolCallId::new("tc-2"),
6875 SelectedPermissionOutcome::new(
6876 acp::PermissionOptionId::new("allow-2"),
6877 acp::PermissionOptionKind::AllowOnce,
6878 ),
6879 cx,
6880 );
6881 });
6882 });
6883
6884 cx.run_until_parked();
6885
6886 cx.read(|cx| {
6887 assert!(
6888 conversation
6889 .read(cx)
6890 .pending_tool_call(&session_id, cx)
6891 .is_none(),
6892 "Expected no pending tool calls after both were authorized"
6893 );
6894 });
6895 }
6896
6897 #[gpui::test]
6898 async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
6899 init_test(cx);
6900
6901 let fs = FakeFs::new(cx.executor());
6902 let project = Project::test(fs, [], cx).await;
6903 let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6904
6905 let parent_session_id = acp::SessionId::new("parent");
6906 let subagent_session_id = acp::SessionId::new("subagent");
6907 let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
6908 let parent_thread =
6909 create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
6910 let subagent_thread = create_test_acp_thread(
6911 Some(acp::SessionId::new("parent")),
6912 "subagent",
6913 connection.clone(),
6914 project.clone(),
6915 cx,
6916 );
6917 let conversation = cx.new(|cx| {
6918 let mut conversation = Conversation::default();
6919 conversation.register_thread(parent_thread.clone(), cx);
6920 conversation.register_thread(subagent_thread.clone(), cx);
6921 conversation
6922 });
6923 (parent_thread, subagent_thread, conversation)
6924 });
6925
6926 let _parent_task =
6927 request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
6928 let _subagent_task =
6929 request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
6930
6931 // Querying with the subagent's session ID returns only the
6932 // subagent's own tool call (subagent path is scoped to its session)
6933 cx.read(|cx| {
6934 let (returned_session_id, tool_call_id, _) = conversation
6935 .read(cx)
6936 .pending_tool_call(&subagent_session_id, cx)
6937 .expect("Expected subagent's pending tool call");
6938 assert_eq!(returned_session_id, subagent_session_id);
6939 assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
6940 });
6941
6942 // Querying with the parent's session ID returns the first pending
6943 // request in FIFO order across all sessions
6944 cx.read(|cx| {
6945 let (returned_session_id, tool_call_id, _) = conversation
6946 .read(cx)
6947 .pending_tool_call(&parent_session_id, cx)
6948 .expect("Expected a pending tool call from parent query");
6949 assert_eq!(returned_session_id, parent_session_id);
6950 assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
6951 });
6952 }
6953
6954 #[gpui::test]
6955 async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
6956 cx: &mut TestAppContext,
6957 ) {
6958 init_test(cx);
6959
6960 let fs = FakeFs::new(cx.executor());
6961 let project = Project::test(fs, [], cx).await;
6962 let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6963
6964 let session_id_a = acp::SessionId::new("thread-a");
6965 let session_id_b = acp::SessionId::new("thread-b");
6966 let (thread_a, thread_b, conversation) = cx.update(|cx| {
6967 let thread_a =
6968 create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
6969 let thread_b =
6970 create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
6971 let conversation = cx.new(|cx| {
6972 let mut conversation = Conversation::default();
6973 conversation.register_thread(thread_a.clone(), cx);
6974 conversation.register_thread(thread_b.clone(), cx);
6975 conversation
6976 });
6977 (thread_a, thread_b, conversation)
6978 });
6979
6980 let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
6981 let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
6982
6983 // Both threads are non-subagent, so pending_tool_call always returns
6984 // the first entry from permission_requests (FIFO across all sessions)
6985 cx.read(|cx| {
6986 let (returned_session_id, tool_call_id, _) = conversation
6987 .read(cx)
6988 .pending_tool_call(&session_id_a, cx)
6989 .expect("Expected a pending tool call");
6990 assert_eq!(returned_session_id, session_id_a);
6991 assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6992 });
6993
6994 // Querying with thread-b also returns thread-a's tool call,
6995 // because non-subagent queries always use permission_requests.first()
6996 cx.read(|cx| {
6997 let (returned_session_id, tool_call_id, _) = conversation
6998 .read(cx)
6999 .pending_tool_call(&session_id_b, cx)
7000 .expect("Expected a pending tool call from thread-b query");
7001 assert_eq!(
7002 returned_session_id, session_id_a,
7003 "Non-subagent queries always return the first pending request in FIFO order"
7004 );
7005 assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
7006 });
7007
7008 // After authorizing thread-a's tool call, thread-b's becomes first
7009 cx.update(|cx| {
7010 conversation.update(cx, |conversation, cx| {
7011 conversation.authorize_tool_call(
7012 session_id_a.clone(),
7013 acp::ToolCallId::new("tc-a"),
7014 SelectedPermissionOutcome::new(
7015 acp::PermissionOptionId::new("allow-a"),
7016 acp::PermissionOptionKind::AllowOnce,
7017 ),
7018 cx,
7019 );
7020 });
7021 });
7022
7023 cx.run_until_parked();
7024
7025 cx.read(|cx| {
7026 let (returned_session_id, tool_call_id, _) = conversation
7027 .read(cx)
7028 .pending_tool_call(&session_id_b, cx)
7029 .expect("Expected thread-b's tool call after thread-a's was authorized");
7030 assert_eq!(returned_session_id, session_id_b);
7031 assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
7032 });
7033 }
7034
7035 #[gpui::test]
7036 async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
7037 init_test(cx);
7038
7039 let (conversation_view, cx) =
7040 setup_conversation_view(StubAgentServer::default_response(), cx).await;
7041
7042 // Add a plain-text message to the queue directly.
7043 active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7044 thread.add_to_queue(
7045 vec![acp::ContentBlock::Text(acp::TextContent::new(
7046 "queued message".to_string(),
7047 ))],
7048 vec![],
7049 cx,
7050 );
7051 // Main editor must be empty for this path — it is by default, but
7052 // assert to make the precondition explicit.
7053 assert!(thread.message_editor.read(cx).is_empty(cx));
7054 thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7055 });
7056
7057 cx.run_until_parked();
7058
7059 // Queue should now be empty.
7060 let queue_len = active_thread(&conversation_view, cx)
7061 .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7062 assert_eq!(queue_len, 0, "Queue should be empty after move");
7063
7064 // Main editor should contain the queued message text.
7065 let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7066 assert_eq!(
7067 text, "queued message",
7068 "Main editor should contain the moved queued message"
7069 );
7070 }
7071
7072 #[gpui::test]
7073 async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
7074 init_test(cx);
7075
7076 let (conversation_view, cx) =
7077 setup_conversation_view(StubAgentServer::default_response(), cx).await;
7078
7079 // Seed the main editor with existing content.
7080 message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
7081 editor.set_message(
7082 vec![acp::ContentBlock::Text(acp::TextContent::new(
7083 "existing content".to_string(),
7084 ))],
7085 window,
7086 cx,
7087 );
7088 });
7089
7090 // Add a plain-text message to the queue.
7091 active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7092 thread.add_to_queue(
7093 vec![acp::ContentBlock::Text(acp::TextContent::new(
7094 "queued message".to_string(),
7095 ))],
7096 vec![],
7097 cx,
7098 );
7099 thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7100 });
7101
7102 cx.run_until_parked();
7103
7104 // Queue should now be empty.
7105 let queue_len = active_thread(&conversation_view, cx)
7106 .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7107 assert_eq!(queue_len, 0, "Queue should be empty after move");
7108
7109 // Main editor should contain existing content + separator + queued content.
7110 let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7111 assert_eq!(
7112 text, "existing content\n\nqueued message",
7113 "Main editor should have existing content and queued message separated by two newlines"
7114 );
7115 }
7116
7117 #[gpui::test]
7118 async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
7119 init_test(cx);
7120
7121 let fs = FakeFs::new(cx.executor());
7122 let project = Project::test(fs, [], cx).await;
7123 let (multi_workspace, cx) =
7124 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7125 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7126
7127 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
7128 let connection_store =
7129 cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
7130
7131 // StubAgentConnection defaults to supports_close_session() -> false
7132 let conversation_view = cx.update(|window, cx| {
7133 cx.new(|cx| {
7134 ConversationView::new(
7135 Rc::new(StubAgentServer::default_response()),
7136 connection_store,
7137 Agent::Custom { id: "Test".into() },
7138 None,
7139 None,
7140 None,
7141 None,
7142 None,
7143 workspace.downgrade(),
7144 project,
7145 Some(thread_store),
7146 None,
7147 "agent_panel",
7148 window,
7149 cx,
7150 )
7151 })
7152 });
7153
7154 cx.run_until_parked();
7155
7156 conversation_view.read_with(cx, |view, _cx| {
7157 let connected = view.as_connected().expect("Should be connected");
7158 assert!(
7159 !connected.threads.is_empty(),
7160 "There should be at least one thread"
7161 );
7162 assert!(
7163 !connected.connection.supports_close_session(),
7164 "StubAgentConnection should not support close"
7165 );
7166 });
7167
7168 conversation_view
7169 .update(cx, |view, cx| {
7170 view.as_connected()
7171 .expect("Should be connected")
7172 .close_all_sessions(cx)
7173 })
7174 .await;
7175 }
7176
7177 #[gpui::test]
7178 async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
7179 init_test(cx);
7180
7181 let (conversation_view, cx) =
7182 setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
7183
7184 cx.run_until_parked();
7185
7186 let close_capable = conversation_view.read_with(cx, |view, _cx| {
7187 let connected = view.as_connected().expect("Should be connected");
7188 assert!(
7189 !connected.threads.is_empty(),
7190 "There should be at least one thread"
7191 );
7192 assert!(
7193 connected.connection.supports_close_session(),
7194 "CloseCapableConnection should support close"
7195 );
7196 connected
7197 .connection
7198 .clone()
7199 .into_any()
7200 .downcast::<CloseCapableConnection>()
7201 .expect("Should be CloseCapableConnection")
7202 });
7203
7204 conversation_view
7205 .update(cx, |view, cx| {
7206 view.as_connected()
7207 .expect("Should be connected")
7208 .close_all_sessions(cx)
7209 })
7210 .await;
7211
7212 let closed_count = close_capable.closed_sessions.lock().len();
7213 assert!(
7214 closed_count > 0,
7215 "close_session should have been called for each thread"
7216 );
7217 }
7218
7219 #[gpui::test]
7220 async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
7221 init_test(cx);
7222
7223 let (conversation_view, cx) =
7224 setup_conversation_view(StubAgentServer::default_response(), cx).await;
7225
7226 cx.run_until_parked();
7227
7228 let result = conversation_view
7229 .update(cx, |view, cx| {
7230 let connected = view.as_connected().expect("Should be connected");
7231 assert!(
7232 !connected.connection.supports_close_session(),
7233 "StubAgentConnection should not support close"
7234 );
7235 let thread_view = connected
7236 .threads
7237 .values()
7238 .next()
7239 .expect("Should have at least one thread");
7240 let session_id = thread_view.read(cx).thread.read(cx).session_id().clone();
7241 connected.connection.clone().close_session(&session_id, cx)
7242 })
7243 .await;
7244
7245 assert!(
7246 result.is_err(),
7247 "close_session should return an error when close is not supported"
7248 );
7249 assert!(
7250 result.unwrap_err().to_string().contains("not supported"),
7251 "Error message should indicate that closing is not supported"
7252 );
7253 }
7254
7255 #[derive(Clone)]
7256 struct CloseCapableConnection {
7257 closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
7258 }
7259
7260 impl CloseCapableConnection {
7261 fn new() -> Self {
7262 Self {
7263 closed_sessions: Arc::new(Mutex::new(Vec::new())),
7264 }
7265 }
7266 }
7267
7268 impl AgentConnection for CloseCapableConnection {
7269 fn agent_id(&self) -> AgentId {
7270 AgentId::new("close-capable")
7271 }
7272
7273 fn telemetry_id(&self) -> SharedString {
7274 "close-capable".into()
7275 }
7276
7277 fn new_session(
7278 self: Rc<Self>,
7279 project: Entity<Project>,
7280 work_dirs: PathList,
7281 cx: &mut gpui::App,
7282 ) -> Task<gpui::Result<Entity<AcpThread>>> {
7283 let action_log = cx.new(|_| ActionLog::new(project.clone()));
7284 let thread = cx.new(|cx| {
7285 AcpThread::new(
7286 None,
7287 Some("CloseCapableConnection".into()),
7288 Some(work_dirs),
7289 self,
7290 project,
7291 action_log,
7292 SessionId::new("close-capable-session"),
7293 watch::Receiver::constant(
7294 acp::PromptCapabilities::new()
7295 .image(true)
7296 .audio(true)
7297 .embedded_context(true),
7298 ),
7299 cx,
7300 )
7301 });
7302 Task::ready(Ok(thread))
7303 }
7304
7305 fn supports_close_session(&self) -> bool {
7306 true
7307 }
7308
7309 fn close_session(
7310 self: Rc<Self>,
7311 session_id: &acp::SessionId,
7312 _cx: &mut App,
7313 ) -> Task<Result<()>> {
7314 self.closed_sessions.lock().push(session_id.clone());
7315 Task::ready(Ok(()))
7316 }
7317
7318 fn auth_methods(&self) -> &[acp::AuthMethod] {
7319 &[]
7320 }
7321
7322 fn authenticate(
7323 &self,
7324 _method_id: acp::AuthMethodId,
7325 _cx: &mut App,
7326 ) -> Task<gpui::Result<()>> {
7327 Task::ready(Ok(()))
7328 }
7329
7330 fn prompt(
7331 &self,
7332 _id: acp_thread::UserMessageId,
7333 _params: acp::PromptRequest,
7334 _cx: &mut App,
7335 ) -> Task<gpui::Result<acp::PromptResponse>> {
7336 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
7337 }
7338
7339 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
7340
7341 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7342 self
7343 }
7344 }
7345}