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