conversation_view.rs

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