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