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