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