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