conversation_view.rs

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