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