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