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