Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:32:53.137Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

agent.rs

6930 lines · 266.3 KB · rust
1mod db;
2mod legacy_thread;
3mod native_agent_server;
4pub mod outline;
5mod pattern_extraction;
6mod sandboxing;
7mod templates;
8#[cfg(test)]
9mod tests;
10mod thread;
11mod thread_store;
12mod tool_permissions;
13mod tools;
14
15use context_server::ContextServerId;
16pub use db::*;
17use itertools::Itertools;
18pub use native_agent_server::NativeAgentServer;
19pub use pattern_extraction::*;
20pub use sandboxing::{
21    ThreadSandbox, sandbox_worktree_writable_paths, settings_sandbox_policy,
22    settings_thread_sandbox,
23};
24pub use shell_command_parser::extract_commands;
25pub use templates::*;
26pub use thread::*;
27pub use thread_store::*;
28pub use tool_permissions::*;
29pub use tools::*;
30
31use acp_thread::{
32    AcpThread, AgentModelId, AgentModelSelector, AgentSessionInfo, AgentSessionList,
33    AgentSessionListRequest, AgentSessionListResponse, ClientUserMessageId, TokenUsageRatio,
34};
35use agent_client_protocol::schema::v1 as acp;
36use agent_skills::{
37    AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTIONS_SIZE, MAX_SKILL_FILE_SIZE, ProjectSkillGroup,
38    SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillLoadWarning, SkillScopeId,
39    SkillSource, SkillSummary, builtin_skills, global_skills_dir, load_skills_from_directory,
40    parse_skill_frontmatter, project_skills_relative_path, read_skill_body_from_content,
41};
42use anyhow::{Context as _, Result, anyhow};
43use chrono::{DateTime, Utc};
44use collections::{HashMap, HashSet, IndexMap};
45
46use fs::Fs;
47use futures::channel::{mpsc, oneshot};
48use futures::future::Shared;
49use futures::{FutureExt as _, StreamExt as _, future};
50use gpui::{
51    App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
52    TaskExt, WeakEntity,
53};
54use language_model::{
55    IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId,
56    LanguageModelRegistry,
57};
58use project::{
59    AgentId, Project, ProjectItem, ProjectPath, Worktree, WorktreeId,
60    trusted_worktrees::TrustedWorktrees,
61};
62use prompt_store::{ProjectContext, RULES_FILE_NAMES, RulesFileContext, WorktreeContext};
63use serde::{Deserialize, Serialize};
64use settings::{LanguageModelSelection, Settings as _, update_settings_file};
65use std::any::Any;
66use std::path::PathBuf;
67use std::rc::Rc;
68use std::sync::{Arc, LazyLock};
69use util::ResultExt;
70use util::path_list::PathList;
71use util::rel_path::RelPath;
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct ProjectSnapshot {
75    pub worktree_snapshots: Vec<project::telemetry_snapshot::TelemetryWorktreeSnapshot>,
76    pub timestamp: DateTime<Utc>,
77}
78
79pub struct RulesLoadingError {
80    pub message: SharedString,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
84pub enum SkillLoadingIssueKind {
85    LoadFailed,
86    DescriptionTooLong,
87    CatalogBudgetExceeded,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Hash)]
91pub struct SkillLoadingIssue {
92    pub project_id: EntityId,
93    pub path: PathBuf,
94    pub message: SharedString,
95    pub kind: SkillLoadingIssueKind,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
99struct SkillLoadingIssueData {
100    path: PathBuf,
101    message: String,
102    kind: SkillLoadingIssueKind,
103}
104
105impl SkillLoadingIssueData {
106    fn from_load_error(error: SkillLoadError) -> Self {
107        Self {
108            path: error.path,
109            message: error.message,
110            kind: SkillLoadingIssueKind::LoadFailed,
111        }
112    }
113
114    fn from_load_warning(skill: &Skill, warning: &SkillLoadWarning) -> Self {
115        let kind = match warning {
116            SkillLoadWarning::DescriptionTooLong { .. } => {
117                SkillLoadingIssueKind::DescriptionTooLong
118            }
119        };
120        Self {
121            path: skill.skill_file_path.clone(),
122            message: warning.message(),
123            kind,
124        }
125    }
126
127    fn catalog_budget_exceeded(path: PathBuf, message: String) -> Self {
128        Self {
129            path,
130            message,
131            kind: SkillLoadingIssueKind::CatalogBudgetExceeded,
132        }
133    }
134}
135
136/// Emitted whenever the set of skill loading issues for a project changes.
137/// The `issues` field is the full replacement list; subscribers should treat
138/// it as a snapshot rather than appending. An empty `issues` list means all
139/// previously-reported issues have been resolved.
140#[derive(Clone, Debug)]
141pub struct SkillLoadingIssuesUpdated {
142    pub project_id: EntityId,
143    pub issues: Vec<SkillLoadingIssue>,
144}
145
146#[derive(Clone, Debug)]
147pub struct NativeAvailableSkill {
148    pub name: String,
149    pub description: String,
150    pub source: SharedString,
151    pub skill_file_path: PathBuf,
152    pub warning: Option<SharedString>,
153}
154
155impl From<&Skill> for NativeAvailableSkill {
156    fn from(skill: &Skill) -> Self {
157        Self {
158            name: skill.name.clone(),
159            description: skill.description.clone(),
160            source: skill.source.display_label().to_string().into(),
161            skill_file_path: skill.skill_file_path.clone(),
162            warning: skill
163                .load_warnings
164                .first()
165                .map(|warning| warning.message().into()),
166        }
167    }
168}
169
170pub const COMPACT_COMMAND_NAME: &str = "compact";
171
172/// Returns the set of MCP prompt names that must be server-qualified
173/// (`/<server>.<name>`) to stay unambiguous in the slash-command popup: names
174/// shared by more than one MCP prompt, or names colliding with a reserved
175/// built-in command (e.g. `/compact`). A built-in always wins an unqualified
176/// invocation, so colliding MCP prompts are only reachable when prefixed.
177fn ambiguous_mcp_prompt_names<'a>(
178    reserved: impl IntoIterator<Item = &'a str>,
179    prompt_names: impl IntoIterator<Item = &'a str>,
180) -> HashSet<&'a str> {
181    let mut counts: HashMap<&str, usize> = HashMap::default();
182    for name in reserved.into_iter().chain(prompt_names) {
183        *counts.entry(name).or_insert(0) += 1;
184    }
185    counts
186        .into_iter()
187        .filter_map(|(name, count)| (count > 1).then_some(name))
188        .collect()
189}
190
191struct ProjectState {
192    project: Entity<Project>,
193    project_context: Entity<ProjectContext>,
194    skills: Arc<Vec<Skill>>,
195    skill_loading_issues: Vec<SkillLoadingIssue>,
196    project_context_needs_refresh: watch::Sender<()>,
197    _maintain_project_context: Task<Result<()>>,
198    context_server_registry: Entity<ContextServerRegistry>,
199    _subscriptions: Vec<Subscription>,
200}
201
202/// Holds both the internal Thread and the AcpThread for a session
203struct Session {
204    /// The internal thread that processes messages
205    thread: Entity<Thread>,
206    /// The ACP thread that handles protocol communication
207    acp_thread: Entity<acp_thread::AcpThread>,
208    project_id: EntityId,
209    pending_save: Task<Result<()>>,
210    _subscriptions: Vec<Subscription>,
211    ref_count: usize,
212}
213
214struct PendingSession {
215    task: Shared<Task<Result<Entity<AcpThread>, Arc<anyhow::Error>>>>,
216    ref_count: usize,
217}
218
219pub struct LanguageModels {
220    /// Access language model by ID
221    models: HashMap<AgentModelId, Arc<dyn LanguageModel>>,
222    /// Cached list for returning language model information
223    model_list: acp_thread::AgentModelList,
224    refresh_models_rx: watch::Receiver<()>,
225    refresh_models_tx: watch::Sender<()>,
226    _authenticate_all_providers_task: Task<()>,
227}
228
229impl LanguageModels {
230    fn new(cx: &mut App) -> Self {
231        let (refresh_models_tx, refresh_models_rx) = watch::channel(());
232
233        let mut this = Self {
234            models: HashMap::default(),
235            model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()),
236            refresh_models_rx,
237            refresh_models_tx,
238            _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx),
239        };
240        this.refresh_list(cx);
241        this
242    }
243
244    fn refresh_list(&mut self, cx: &App) {
245        let providers = LanguageModelRegistry::global(cx)
246            .read(cx)
247            .visible_providers()
248            .into_iter()
249            .filter(|provider| provider.is_authenticated(cx))
250            .collect::<Vec<_>>();
251
252        let mut language_model_list = IndexMap::default();
253        let mut recommended_models = HashSet::default();
254
255        let mut recommended = Vec::new();
256        for provider in &providers {
257            for model in provider.recommended_models(cx) {
258                recommended_models.insert((model.provider_id(), model.id()));
259                recommended.push(Self::map_language_model_to_info(&model, provider));
260            }
261        }
262        if !recommended.is_empty() {
263            language_model_list.insert(
264                acp_thread::AgentModelGroupName("Recommended".into()),
265                recommended,
266            );
267        }
268
269        let mut models = HashMap::default();
270        for provider in providers {
271            let mut provider_models = Vec::new();
272            for model in provider.provided_models(cx) {
273                let model_info = Self::map_language_model_to_info(&model, &provider);
274                let model_id = model_info.id.clone();
275                provider_models.push(model_info);
276                models.insert(model_id, model);
277            }
278            if !provider_models.is_empty() {
279                language_model_list.insert(
280                    acp_thread::AgentModelGroupName(provider.name().0.clone()),
281                    provider_models,
282                );
283            }
284        }
285
286        self.models = models;
287        self.model_list = acp_thread::AgentModelList::Grouped(language_model_list);
288        self.refresh_models_tx.send(()).ok();
289    }
290
291    fn watch(&self) -> watch::Receiver<()> {
292        self.refresh_models_rx.clone()
293    }
294
295    pub fn notify_model_selection_changed(&mut self) {
296        self.refresh_models_tx.send(()).ok();
297    }
298
299    pub fn model_from_id(&self, model_id: &AgentModelId) -> Option<Arc<dyn LanguageModel>> {
300        self.models.get(model_id).cloned()
301    }
302
303    fn map_language_model_to_info(
304        model: &Arc<dyn LanguageModel>,
305        provider: &Arc<dyn LanguageModelProvider>,
306    ) -> acp_thread::AgentModelInfo {
307        acp_thread::AgentModelInfo {
308            id: Self::model_id(model),
309            name: model.name().0,
310            description: None,
311            icon: Some(match provider.icon() {
312                IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path),
313                IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
314            }),
315            is_latest: model.is_latest(),
316            cost: model.model_cost_info().map(|cost| cost.to_shared_string()),
317            disabled: model.is_disabled(),
318        }
319    }
320
321    fn model_id(model: &Arc<dyn LanguageModel>) -> AgentModelId {
322        AgentModelId::new(format!("{}/{}", model.provider_id().0, model.id().0))
323    }
324
325    fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
326        let authenticate_all_providers = LanguageModelRegistry::global(cx)
327            .read(cx)
328            .visible_providers()
329            .iter()
330            .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
331            .collect::<Vec<_>>();
332
333        cx.spawn(async move |cx| {
334            for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
335                if let Err(err) = authenticate_task.await {
336                    match err {
337                        language_model::AuthenticateError::CredentialsNotFound => {
338                            // Since we're authenticating these providers in the
339                            // background for the purposes of populating the
340                            // language selector, we don't care about providers
341                            // where the credentials are not found.
342                        }
343                        language_model::AuthenticateError::ConnectionRefused => {
344                            // Not logging connection refused errors as they are mostly from LM Studio's noisy auth failures.
345                            // LM Studio only has one auth method (endpoint call) which fails for users who haven't enabled it.
346                            // TODO: Better manage LM Studio auth logic to avoid these noisy failures.
347                        }
348                        _ => {
349                            // Some providers have noisy failure states that we
350                            // don't want to spam the logs with every time the
351                            // language model selector is initialized.
352                            //
353                            // Ideally these should have more clear failure modes
354                            // that we know are safe to ignore here, like what we do
355                            // with `CredentialsNotFound` above.
356                            match provider_id.0.as_ref() {
357                                "lmstudio" | "ollama" => {
358                                    // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
359                                    //
360                                    // These fail noisily, so we don't log them.
361                                }
362                                "copilot_chat" => {
363                                    // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
364                                }
365                                _ => {
366                                    log::error!(
367                                        "Failed to authenticate provider: {}: {err:#}",
368                                        provider_name.0
369                                    );
370                                }
371                            }
372                        }
373                    }
374                }
375            }
376
377            cx.update(|cx| {
378                LanguageModelRegistry::global(cx)
379                    .update(cx, |registry, cx| registry.refresh_fallback_model(cx))
380            });
381        })
382    }
383}
384
385/// Implemented by the UI layer to provide the ability for agent tools to create
386/// sibling threads that appear in the agent panel.
387///
388/// `agent_ui::AgentPanel` installs an implementation of this trait on the
389/// `NativeAgent` when it sets up a connection. Tools in a native-agent thread
390/// then discover and use the host via `NativeThreadEnvironment`. The UI side
391/// is responsible for keeping the installed host current; a host whose
392/// backing UI has been torn down will fail its first request with a clear
393/// error rather than being detected up front.
394pub trait SiblingThreadHost {
395    fn create_sibling_thread(
396        &self,
397        request: SiblingThreadRequest,
398        cx: &mut AsyncApp,
399    ) -> Task<Result<SiblingThreadInfo>>;
400
401    fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents>;
402}
403
404pub struct NativeAgent {
405    /// Session ID -> Session mapping
406    sessions: HashMap<acp::SessionId, Session>,
407    pending_sessions: HashMap<acp::SessionId, PendingSession>,
408    thread_store: Entity<ThreadStore>,
409    /// Project-specific state keyed by project EntityId
410    projects: HashMap<EntityId, ProjectState>,
411    /// Shared templates for all threads
412    templates: Arc<Templates>,
413    /// Cached model information
414    models: LanguageModels,
415    /// Handler installed by the UI for `create_thread` / `list_agents_and_models` tools.
416    sibling_thread_host: Option<Rc<dyn SiblingThreadHost>>,
417    fs: Arc<dyn Fs>,
418    _subscriptions: Vec<Subscription>,
419    /// Tracks the lifecycle of global skills directory observation. We
420    /// don't eagerly watch (or even check for) `~/.agents/skills/` at
421    /// startup; users who never engage with the agent panel pay zero
422    /// filesystem cost. The watch is kicked off lazily by
423    /// [`Self::ensure_skills_scan_started`], which is called from the
424    /// three agent-panel interaction points: input box focus, slash
425    /// autocomplete, and conversation submit.
426    skills_state: SkillsState,
427}
428
429#[derive(Default)]
430enum SkillsState {
431    /// No scan or watch is active. A user-interaction trigger will kick
432    /// off a fresh scan.
433    #[default]
434    Idle,
435    /// A one-shot scan task is in flight. It checks whether
436    /// `~/.agents/skills/` exists; if so, transitions to `Watching`,
437    /// otherwise back to `Idle`.
438    Scanning,
439    /// A watch task is observing `~/.agents/skills/`. It transitions
440    /// back to `Idle` if the watched directory itself is removed.
441    Watching,
442}
443
444impl gpui::EventEmitter<SkillLoadingIssuesUpdated> for NativeAgent {}
445
446static RULES_FILE_REL_PATHS: LazyLock<Vec<Arc<RelPath>>> = LazyLock::new(|| {
447    RULES_FILE_NAMES
448        .iter()
449        .filter_map(|name| {
450            RelPath::from_unix_str(name)
451                .ok()
452                .map(|path| path.into_arc())
453        })
454        .collect()
455});
456
457static AGENTS_PREFIX: LazyLock<Option<Arc<RelPath>>> = LazyLock::new(|| {
458    RelPath::from_unix_str(AGENTS_DIR_NAME)
459        .ok()
460        .map(|path| path.into_arc())
461});
462
463static SKILLS_PREFIX: LazyLock<Option<Arc<RelPath>>> = LazyLock::new(|| {
464    RelPath::from_unix_str(project_skills_relative_path())
465        .ok()
466        .map(|path| path.into_arc())
467});
468
469struct ProjectSkillFile {
470    relative_path: Arc<RelPath>,
471    display_path: PathBuf,
472    size: u64,
473}
474
475async fn expand_worktree_directory(
476    worktree: &Entity<Worktree>,
477    path: &RelPath,
478    cx: &mut AsyncApp,
479) -> Result<()> {
480    let expand_task = worktree.update(cx, |worktree, cx| {
481        let entry_id = worktree
482            .entry_for_path(path)
483            .filter(|entry| entry.is_dir())
484            .map(|entry| entry.id);
485        entry_id.and_then(|entry_id| worktree.expand_entry(entry_id, cx))
486    });
487
488    if let Some(expand_task) = expand_task {
489        expand_task.await?;
490    }
491
492    Ok(())
493}
494
495async fn expand_project_skills_directories(
496    worktree: &Entity<Worktree>,
497    cx: &mut AsyncApp,
498) -> Result<()> {
499    let agents_dir = RelPath::from_unix_str(AGENTS_DIR_NAME)?;
500    let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else {
501        return Ok(());
502    };
503
504    expand_worktree_directory(worktree, agents_dir, cx).await?;
505    expand_worktree_directory(worktree, skills_prefix, cx).await?;
506
507    let skill_dirs = worktree.update(cx, |worktree, _cx| {
508        worktree
509            .child_entries(skills_prefix)
510            .filter(|entry| entry.is_dir())
511            .map(|entry| entry.path.clone())
512            .collect::<Vec<_>>()
513    });
514    for skill_dir in skill_dirs {
515        expand_worktree_directory(worktree, &skill_dir, cx).await?;
516    }
517
518    Ok(())
519}
520
521fn project_skill_files_from_worktree(worktree: &Worktree) -> Vec<ProjectSkillFile> {
522    let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else {
523        return Vec::new();
524    };
525    let Ok(skill_file_name) = RelPath::from_unix_str(SKILL_FILE_NAME) else {
526        return Vec::new();
527    };
528
529    let mut skill_files = Vec::new();
530    for skill_dir in worktree.child_entries(skills_prefix) {
531        if !skill_dir.is_dir() {
532            continue;
533        }
534
535        let relative_path = skill_dir.path.join(skill_file_name);
536        let Some(skill_file) = worktree.entry_for_path(&relative_path) else {
537            continue;
538        };
539        if !skill_file.is_file() {
540            continue;
541        }
542
543        skill_files.push(ProjectSkillFile {
544            display_path: worktree.absolutize(&relative_path),
545            relative_path: relative_path.into(),
546            size: skill_file.size,
547        });
548    }
549
550    skill_files.sort_by(|a, b| {
551        a.relative_path
552            .as_unix_str()
553            .cmp(b.relative_path.as_unix_str())
554    });
555    skill_files
556}
557
558impl NativeAgent {
559    pub fn new(
560        thread_store: Entity<ThreadStore>,
561        templates: Arc<Templates>,
562        fs: Arc<dyn Fs>,
563        cx: &mut App,
564    ) -> Entity<NativeAgent> {
565        log::debug!("Creating new NativeAgent");
566
567        cx.new(|cx| {
568            let subscriptions = vec![
569                cx.subscribe(
570                    &LanguageModelRegistry::global(cx),
571                    Self::handle_models_updated_event,
572                ),
573                // Flush thread content on quit so an in-flight async save
574                // can't leave a thread orphaned ("no thread found with ID").
575                cx.on_app_quit(Self::flush_threads_on_quit),
576            ];
577
578            if !cx.has_global::<SkillIndex>() {
579                cx.set_global(SkillIndex::default());
580            }
581
582            Self {
583                sessions: HashMap::default(),
584                pending_sessions: HashMap::default(),
585                thread_store,
586                projects: HashMap::default(),
587                templates,
588                models: LanguageModels::new(cx),
589                sibling_thread_host: None,
590                fs,
591                _subscriptions: subscriptions,
592                skills_state: SkillsState::default(),
593            }
594        })
595    }
596
597    /// Kicks off a one-time scan of the global skills directory if one
598    /// isn't already in progress and a watch isn't already active.
599    ///
600    /// Idempotent and cheap: returns immediately if a scan or watch is
601    /// already running. The expected callers are user-interaction events
602    /// from the agent panel (input focus, slash autocomplete, conversation
603    /// submit); firing this from any of them is equivalent and safe to
604    /// repeat.
605    ///
606    /// The scan itself runs detached on the foreground executor. If
607    /// `~/.agents/skills/` exists it transitions state to
608    /// [`SkillsState::Watching`] and starts a recursive watch;
609    /// otherwise it transitions back to [`SkillsState::Idle`] so the
610    /// next trigger retries (covering the case where the user creates
611    /// the directory after the first scan).
612    pub fn ensure_skills_scan_started(&mut self, cx: &mut Context<Self>) {
613        if !matches!(self.skills_state, SkillsState::Idle) {
614            return;
615        }
616        self.skills_state = SkillsState::Scanning;
617        let fs = self.fs.clone();
618        cx.spawn(async move |this, cx| Self::run_skills_scan(this, fs, cx).await)
619            .detach();
620    }
621
622    async fn run_skills_scan(this: WeakEntity<Self>, fs: Arc<dyn Fs>, cx: &mut AsyncApp) {
623        let skills_dir = global_skills_dir();
624        if !fs.is_dir(&skills_dir).await {
625            // Skills directory doesn't exist; revert state so the next
626            // user trigger retries.
627            let _ = this.update(cx, |this, _cx| {
628                this.skills_state = SkillsState::Idle;
629            });
630            return;
631        }
632
633        // Skills directory exists. Start a watch and trigger a refresh
634        // of every project's context so the freshly-discovered skills
635        // get loaded.
636        let _ = this.update(cx, |this, cx| {
637            cx.spawn({
638                let fs = fs.clone();
639                let skills_dir = skills_dir.clone();
640                async move |this, cx| Self::run_skills_watch(this, fs, skills_dir, cx).await
641            })
642            .detach();
643            this.skills_state = SkillsState::Watching;
644            for state in this.projects.values_mut() {
645                state.project_context_needs_refresh.send(()).ok();
646            }
647        });
648    }
649
650    async fn run_skills_watch(
651        this: WeakEntity<Self>,
652        fs: Arc<dyn Fs>,
653        skills_dir: PathBuf,
654        cx: &mut AsyncApp,
655    ) {
656        let (mut events, watcher) = fs
657            .watch(&skills_dir, std::time::Duration::from_millis(500))
658            .await;
659
660        // Linux's inotify backend is non-recursive, so a watch on
661        // `skills_dir` only fires for direct children. Skill discovery
662        // is intentionally one level deep (`<skills_dir>/<skill>/SKILL.md`),
663        // so we only register watches on each immediate child directory
664        // and deliberately do NOT recurse: a stray `node_modules`,
665        // `target`, or `.git` inside a skill folder would otherwise
666        // register watches for tens of thousands of subdirectories.
667        // These per-child adds are cheap no-ops on macOS/Windows where
668        // the OS-level watch is already recursive.
669        if let Ok(mut entries) = fs.read_dir(&skills_dir).await {
670            while let Some(entry) = entries.next().await {
671                let Ok(path) = entry else { continue };
672                if let Ok(Some(metadata)) = fs.metadata(&path).await
673                    && metadata.is_dir
674                {
675                    watcher.add(&path).ok();
676                }
677            }
678        }
679
680        while let Some(events) = events.next().await {
681            // When a new immediate child directory of `skills_dir` is
682            // created, add a single watch for it so changes to its
683            // `SKILL.md` are observed on Linux. We intentionally do not
684            // recurse into the new directory — skill discovery is only
685            // one level deep.
686            for event in &events {
687                if event.kind == Some(fs::PathEventKind::Created)
688                    && event.path.parent() == Some(skills_dir.as_path())
689                    && fs.is_dir(&event.path).await
690                {
691                    watcher.add(&event.path).ok();
692                }
693            }
694
695            let watched_root_removed = events.iter().any(|event| {
696                event.path == skills_dir && event.kind == Some(fs::PathEventKind::Removed)
697            });
698
699            let updated = this.update(cx, |this, _cx| {
700                for state in this.projects.values_mut() {
701                    state.project_context_needs_refresh.send(()).ok();
702                }
703                if watched_root_removed {
704                    // Drop back to Idle so the next user trigger
705                    // retries the scan; the next trigger will rediscover
706                    // the directory if the user has recreated it.
707                    this.skills_state = SkillsState::Idle;
708                }
709            });
710            if updated.is_err() || watched_root_removed {
711                return;
712            }
713        }
714    }
715
716    pub fn set_sibling_thread_host(&mut self, host: Rc<dyn SiblingThreadHost>) {
717        self.sibling_thread_host = Some(host);
718    }
719
720    pub fn sibling_thread_host(&self) -> Option<Rc<dyn SiblingThreadHost>> {
721        self.sibling_thread_host.clone()
722    }
723
724    fn new_session(
725        &mut self,
726        project: Entity<Project>,
727        cx: &mut Context<Self>,
728    ) -> Entity<AcpThread> {
729        let project_id = self.get_or_create_project_state(&project, cx);
730        let project_state = &self.projects[&project_id];
731
732        let registry = LanguageModelRegistry::read_global(cx);
733        let available_count = registry.available_models(cx).count();
734        log::debug!("Total available models: {}", available_count);
735
736        let default_model = registry.default_model().and_then(|default_model| {
737            self.models
738                .model_from_id(&LanguageModels::model_id(&default_model.model))
739        });
740        let thread = cx.new(|cx| {
741            Thread::new(
742                project,
743                project_state.project_context.clone(),
744                project_state.context_server_registry.clone(),
745                self.templates.clone(),
746                default_model,
747                cx,
748            )
749        });
750
751        self.register_session(thread, project_id, 1, cx)
752    }
753
754    fn register_session(
755        &mut self,
756        thread_handle: Entity<Thread>,
757        project_id: EntityId,
758        ref_count: usize,
759        cx: &mut Context<Self>,
760    ) -> Entity<AcpThread> {
761        let connection = Rc::new(NativeAgentConnection(cx.entity()));
762
763        let thread = thread_handle.read(cx);
764        let session_id = thread.id().clone();
765        let parent_session_id = thread.parent_thread_id();
766        let title = thread.title();
767        let draft_prompt = thread.draft_prompt().map(Vec::from);
768        let scroll_position = thread.ui_scroll_position();
769        let token_usage = thread.latest_token_usage();
770        let project = thread.project.clone();
771        let action_log = thread.action_log.clone();
772        let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone();
773        let acp_thread = cx.new(|cx| {
774            let mut acp_thread = acp_thread::AcpThread::new(
775                parent_session_id,
776                title,
777                None,
778                connection,
779                project.clone(),
780                action_log.clone(),
781                session_id.clone(),
782                prompt_capabilities_rx,
783                cx,
784            );
785            acp_thread.set_draft_prompt(draft_prompt, cx);
786            acp_thread.set_ui_scroll_position(scroll_position);
787            acp_thread.update_token_usage(token_usage, cx);
788            acp_thread
789        });
790
791        let registry = LanguageModelRegistry::read_global(cx);
792        let summarization_model = registry.thread_summary_model(cx).map(|c| c.model);
793
794        let weak = cx.weak_entity();
795        let weak_thread = thread_handle.downgrade();
796        thread_handle.update(cx, |thread, cx| {
797            thread.set_summarization_model(summarization_model, cx);
798            thread.add_default_tools(
799                Rc::new(NativeThreadEnvironment {
800                    acp_thread: acp_thread.downgrade(),
801                    thread: weak_thread,
802                    agent: weak.clone(),
803                }) as _,
804                cx,
805            );
806            // The resolver closure reads `state.skills` at invocation
807            // time, so skills added or removed by the SKILL.md watcher
808            // after the thread is constructed are still visible to the
809            // model — without this, the catalog and tool would drift out
810            // of sync until the session was reopened.
811            thread.add_tool(SkillTool::with_body_resolver(
812                skills_resolver_for_project(weak.clone(), project_id),
813                skill_body_resolver_for_project(project.clone(), self.fs.clone()),
814            ));
815        });
816
817        let subscriptions = vec![
818            cx.subscribe(&thread_handle, Self::handle_thread_title_updated),
819            cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated),
820            cx.observe(&thread_handle, move |this, thread, cx| {
821                this.save_thread(thread, cx)
822            }),
823        ];
824
825        self.sessions.insert(
826            session_id,
827            Session {
828                thread: thread_handle,
829                acp_thread: acp_thread.clone(),
830                project_id,
831                _subscriptions: subscriptions,
832                pending_save: Task::ready(Ok(())),
833                ref_count,
834            },
835        );
836
837        self.update_available_commands_for_project(project_id, cx);
838
839        acp_thread
840    }
841
842    pub fn models(&self) -> &LanguageModels {
843        &self.models
844    }
845
846    fn get_or_create_project_state(
847        &mut self,
848        project: &Entity<Project>,
849        cx: &mut Context<Self>,
850    ) -> EntityId {
851        let project_id = project.entity_id();
852        if self.projects.contains_key(&project_id) {
853            return project_id;
854        }
855
856        let project_context = cx.new(|_| ProjectContext::new(vec![]));
857        self.register_project_with_initial_context(project.clone(), project_context, cx);
858        if let Some(state) = self.projects.get_mut(&project_id) {
859            state.project_context_needs_refresh.send(()).ok();
860        }
861        project_id
862    }
863
864    fn register_project_with_initial_context(
865        &mut self,
866        project: Entity<Project>,
867        project_context: Entity<ProjectContext>,
868        cx: &mut Context<Self>,
869    ) {
870        let project_id = project.entity_id();
871
872        let context_server_store = project.read(cx).context_server_store();
873        let context_server_registry =
874            cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
875
876        let mut subscriptions = vec![
877            cx.subscribe(&project, Self::handle_project_event),
878            cx.subscribe(
879                &context_server_store,
880                Self::handle_context_server_store_updated,
881            ),
882            cx.subscribe(
883                &context_server_registry,
884                Self::handle_context_server_registry_event,
885            ),
886        ];
887        // When the user trusts a worktree (or revokes trust), project-local
888        // skills become eligible (or ineligible) for loading. Trigger a
889        // refresh so the catalog and slash-command list update without a
890        // restart. This is unconditional — a `Trusted` event for any
891        // worktree under any project is cheap to handle and keeps the
892        // logic straightforward.
893        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
894            subscriptions.push(
895                cx.subscribe(&trusted_worktrees, move |this, _, _event, _cx| {
896                    if let Some(state) = this.projects.get_mut(&project_id) {
897                        state.project_context_needs_refresh.send(()).ok();
898                    }
899                }),
900            );
901        }
902
903        let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
904            watch::channel(());
905
906        self.projects.insert(
907            project_id,
908            ProjectState {
909                project,
910                project_context,
911                skills: Arc::new(Vec::new()),
912                skill_loading_issues: Vec::new(),
913                project_context_needs_refresh: project_context_needs_refresh_tx,
914                _maintain_project_context: cx.spawn(async move |this, cx| {
915                    Self::maintain_project_context(
916                        this,
917                        project_id,
918                        project_context_needs_refresh_rx,
919                        cx,
920                    )
921                    .await
922                }),
923                context_server_registry,
924                _subscriptions: subscriptions,
925            },
926        );
927    }
928
929    fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> {
930        self.sessions
931            .get(session_id)
932            .and_then(|session| self.projects.get(&session.project_id))
933    }
934
935    async fn maintain_project_context(
936        this: WeakEntity<Self>,
937        project_id: EntityId,
938        mut needs_refresh: watch::Receiver<()>,
939        cx: &mut AsyncApp,
940    ) -> Result<()> {
941        while needs_refresh.changed().await.is_ok() {
942            let task = this.update(cx, |this, cx| {
943                let state = this
944                    .projects
945                    .get(&project_id)
946                    .context("project state not found")?;
947                anyhow::Ok(Self::build_project_context(
948                    &state.project,
949                    this.fs.clone(),
950                    cx,
951                ))
952            })??;
953            let (project_context, skills, skill_issue_data) = task.await;
954            let skills = Arc::new(skills);
955            let skill_loading_issues: Vec<SkillLoadingIssue> = skill_issue_data
956                .into_iter()
957                .map(|issue| SkillLoadingIssue {
958                    project_id,
959                    path: issue.path,
960                    message: issue.message.into(),
961                    kind: issue.kind,
962                })
963                .collect();
964            this.update(cx, |this, cx| {
965                // Only emit SkillLoadingIssuesUpdated when the issue list
966                // actually changed. Refreshes happen frequently (prompt-store
967                // updates, rules-file edits, worktree events, trust-state
968                // changes), and re-emitting an unchanged list causes the UI
969                // to redisplay issues the user has already dismissed.
970                // Transitions from non-empty to empty still count as a change,
971                // so subscribers continue to receive an empty list to clear
972                // previously-displayed issues when they get resolved.
973                let issues_changed = this
974                    .projects
975                    .get(&project_id)
976                    .map(|state| state.skill_loading_issues != skill_loading_issues)
977                    .unwrap_or(true);
978
979                if let Some(state) = this.projects.get_mut(&project_id) {
980                    state.skills = skills;
981                    state.skill_loading_issues = skill_loading_issues.clone();
982                    // Only push the new `ProjectContext` through if it
983                    // differs from the current one. The system prompt is
984                    // re-rendered from this on every turn, so an unchanged
985                    // `ProjectContext` means a byte-identical system prompt
986                    // and a continued hit on the model API's prompt cache.
987                    // Refreshes fire on many events that don't actually
988                    // change what the model sees (e.g. a SKILL.md body edit
989                    // that leaves the catalog — name, description, location
990                    // — untouched), so this check matters in practice.
991                    state
992                        .project_context
993                        .update(cx, |current_project_context, cx| {
994                            if *current_project_context != project_context {
995                                *current_project_context = project_context;
996                                cx.notify();
997                            }
998                        });
999                }
1000                if issues_changed {
1001                    cx.emit(SkillLoadingIssuesUpdated {
1002                        project_id,
1003                        issues: skill_loading_issues,
1004                    });
1005                }
1006                // Skills appear in the slash-command list, so a change in
1007                // the loaded skills needs to be pushed out to active sessions.
1008                // This runs unconditionally because MCP prompts (also part of
1009                // the available commands) can change without affecting the
1010                // skill error list.
1011                this.update_available_commands_for_project(project_id, cx);
1012                this.publish_skill_index(cx);
1013            })?;
1014        }
1015
1016        Ok(())
1017    }
1018
1019    fn build_project_context(
1020        project: &Entity<Project>,
1021        fs: Arc<dyn Fs>,
1022        cx: &mut App,
1023    ) -> Task<(ProjectContext, Vec<Skill>, Vec<SkillLoadingIssueData>)> {
1024        let worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
1025        let worktree_tasks = worktrees
1026            .iter()
1027            .map(|worktree| {
1028                Self::load_worktree_info_for_system_prompt(worktree.clone(), project.clone(), cx)
1029            })
1030            .collect::<Vec<_>>();
1031
1032        // Load global skills
1033        let global_skills_task = {
1034            let global_skills_dir = global_skills_dir();
1035            let global_skills_fs = fs.clone();
1036            cx.background_spawn(async move {
1037                load_skills_from_directory(
1038                    &global_skills_fs,
1039                    &global_skills_dir,
1040                    SkillSource::Global,
1041                )
1042                .await
1043            })
1044        };
1045
1046        // Load project-local skills, but only from worktrees the user has
1047        // trusted. Skills in `.agents/skills/` ship with the project; a
1048        // freshly cloned untrusted repo can carry hostile descriptions or
1049        // bodies, so we keep them out of the catalog and the slash-command
1050        // list until trust is granted. The subscription in
1051        // `register_project_with_initial_context` triggers a context
1052        // refresh when a worktree's trust state changes, so newly trusted
1053        // worktrees pick up their skills without restarting.
1054        let trusted_worktrees = TrustedWorktrees::try_get_global(cx);
1055        let worktree_store = project.read(cx).worktree_store();
1056        let project_skills_task = {
1057            let project = project.clone();
1058            let trusted_worktrees = worktrees
1059                .iter()
1060                .filter_map(|worktree| {
1061                    let worktree_id = worktree.read(cx).id();
1062                    let is_trusted = trusted_worktrees.as_ref().is_none_or(|trusted_worktrees| {
1063                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
1064                            trusted_worktrees.can_trust(&worktree_store, worktree_id, cx)
1065                        })
1066                    });
1067                    if !is_trusted {
1068                        return None;
1069                    }
1070
1071                    let worktree_snapshot = worktree.read(cx);
1072                    let worktree_root_name: Arc<str> = worktree_snapshot.root_name_str().into();
1073                    let scan_complete = worktree_snapshot
1074                        .as_local()
1075                        .map(|local| local.scan_complete());
1076                    Some((
1077                        worktree.clone(),
1078                        worktree_id,
1079                        worktree_root_name,
1080                        scan_complete,
1081                    ))
1082                })
1083                .collect::<Vec<_>>();
1084
1085            cx.spawn(async move |cx| {
1086                let mut project_skills_results = Vec::new();
1087                for (worktree, worktree_id, worktree_root_name, scan_complete) in trusted_worktrees
1088                {
1089                    if let Some(scan_complete) = scan_complete {
1090                        scan_complete.await;
1091                    }
1092                    if let Err(error) = expand_project_skills_directories(&worktree, cx).await {
1093                        project_skills_results.push(vec![Err(SkillLoadError {
1094                            path: PathBuf::from(project_skills_relative_path()),
1095                            message: format!("Failed to scan project skills: {}", error),
1096                        })]);
1097                        continue;
1098                    }
1099
1100                    let skill_files = worktree.update(cx, |worktree, _cx| {
1101                        project_skill_files_from_worktree(worktree)
1102                    });
1103                    let source = SkillSource::ProjectLocal {
1104                        worktree_id: SkillScopeId(worktree_id.to_usize()),
1105                        worktree_root_name,
1106                    };
1107
1108                    let mut worktree_results = Vec::new();
1109                    for skill_file in skill_files {
1110                        if skill_file.size > MAX_SKILL_FILE_SIZE as u64 {
1111                            worktree_results.push(Err(SkillLoadError {
1112                                path: skill_file.display_path.clone(),
1113                                message: format!(
1114                                    "SKILL.md file exceeds maximum size of {}KB",
1115                                    MAX_SKILL_FILE_SIZE / 1024
1116                                ),
1117                            }));
1118                            continue;
1119                        }
1120
1121                        let buffer = match project
1122                            .update(cx, |project, cx| {
1123                                project.open_buffer(
1124                                    (worktree_id, skill_file.relative_path.clone()),
1125                                    cx,
1126                                )
1127                            })
1128                            .await
1129                        {
1130                            Ok(buffer) => buffer,
1131                            Err(error) => {
1132                                worktree_results.push(Err(SkillLoadError {
1133                                    path: skill_file.display_path.clone(),
1134                                    message: format!("Failed to read file: {}", error),
1135                                }));
1136                                continue;
1137                            }
1138                        };
1139
1140                        let content = cx
1141                            .update(|cx| buffer.read(cx).as_text_snapshot().as_rope().to_string());
1142
1143                        worktree_results.push(
1144                            parse_skill_frontmatter(
1145                                &skill_file.display_path,
1146                                &content,
1147                                source.clone(),
1148                            )
1149                            .map_err(|error| SkillLoadError {
1150                                path: skill_file.display_path,
1151                                message: error.to_string(),
1152                            }),
1153                        );
1154                    }
1155                    project_skills_results.push(worktree_results);
1156                }
1157                project_skills_results
1158            })
1159        };
1160        cx.spawn(async move |_cx| {
1161            let worktrees = future::join_all(worktree_tasks).await;
1162
1163            let worktrees = worktrees
1164                .into_iter()
1165                .map(|(worktree, _rules_error)| {
1166                    // TODO: show error message
1167                    // if let Some(rules_error) = rules_error {
1168                    //     this.update(cx, |_, cx| cx.emit(rules_error)).ok();
1169                    // }
1170                    worktree
1171                })
1172                .collect::<Vec<_>>();
1173
1174            // Load and combine skills. `combine_skills` deliberately
1175            // does NOT deduplicate — the autocomplete popup needs to
1176            // see every entry so users can disambiguate same-named
1177            // global vs. project-local skills via the source label.
1178            // Project-overrides-global is applied below, only for the
1179            // model-facing catalog.
1180            let global_skills = global_skills_task.await;
1181            let project_skills_results = project_skills_task.await;
1182            let (skills, skill_errors) =
1183                combine_skills(global_skills, project_skills_results.into_iter().flatten());
1184            let mut skill_issues = skill_errors
1185                .into_iter()
1186                .map(SkillLoadingIssueData::from_load_error)
1187                .collect::<Vec<_>>();
1188            for skill in &skills {
1189                skill_issues.extend(
1190                    skill
1191                        .load_warnings
1192                        .iter()
1193                        .map(|warning| SkillLoadingIssueData::from_load_warning(skill, warning)),
1194                );
1195            }
1196
1197            // Apply project-overrides-global before catalog selection
1198            // so the model sees at most one entry per name. The full
1199            // `skills` list is still stored on `ProjectState` and used
1200            // by the autocomplete popup.
1201            let overridden = apply_skill_overrides(&skills);
1202
1203            // Enforce the catalog size budget here so that skills which
1204            // don't fit produce an issue in the UI rather than being
1205            // silently swallowed by ProjectContext.
1206            let (catalog_skills, budget_issues) = select_catalog_skills(&overridden);
1207            skill_issues.extend(budget_issues);
1208
1209            let project_context = ProjectContext::new(worktrees).with_skills(catalog_skills);
1210            (project_context, skills, skill_issues)
1211        })
1212    }
1213
1214    fn load_worktree_info_for_system_prompt(
1215        worktree: Entity<Worktree>,
1216        project: Entity<Project>,
1217        cx: &mut App,
1218    ) -> Task<(WorktreeContext, Option<RulesLoadingError>)> {
1219        let tree = worktree.read(cx);
1220        let root_name = tree.root_name_str().into();
1221        let abs_path = tree.abs_path();
1222        let scan_complete = tree.as_local().map(|local| local.scan_complete());
1223
1224        let mut context = WorktreeContext {
1225            root_name,
1226            abs_path,
1227            rules_file: None,
1228        };
1229
1230        cx.spawn(async move |cx| {
1231            if let Some(scan_complete) = scan_complete {
1232                scan_complete.await;
1233            }
1234
1235            let rules_task = cx.update(|cx| Self::load_worktree_rules_file(worktree, project, cx));
1236
1237            let (rules_file, rules_file_error) = match rules_task {
1238                Some(rules_task) => match rules_task.await {
1239                    Ok(rules_file) => (Some(rules_file), None),
1240                    Err(err) => (
1241                        None,
1242                        Some(RulesLoadingError {
1243                            message: format!("{err}").into(),
1244                        }),
1245                    ),
1246                },
1247                None => (None, None),
1248            };
1249            context.rules_file = rules_file;
1250            (context, rules_file_error)
1251        })
1252    }
1253
1254    fn load_worktree_rules_file(
1255        worktree: Entity<Worktree>,
1256        project: Entity<Project>,
1257        cx: &mut App,
1258    ) -> Option<Task<Result<RulesFileContext>>> {
1259        let worktree = worktree.read(cx);
1260        let worktree_id = worktree.id();
1261        let selected_rules_file = RULES_FILE_REL_PATHS
1262            .iter()
1263            .filter_map(|name| {
1264                worktree
1265                    .entry_for_path(name)
1266                    .filter(|entry| entry.is_file())
1267                    .map(|entry| entry.path.clone())
1268            })
1269            .next();
1270
1271        // Note that Cline supports `.clinerules` being a directory, but that is not currently
1272        // supported. This doesn't seem to occur often in GitHub repositories.
1273        selected_rules_file.map(|path_in_worktree| {
1274            let project_path = ProjectPath {
1275                worktree_id,
1276                path: path_in_worktree.clone(),
1277            };
1278            let buffer_task =
1279                project.update(cx, |project, cx| project.open_buffer(project_path, cx));
1280            let rope_task = cx.spawn(async move |cx| {
1281                let buffer = buffer_task.await?;
1282                let (project_entry_id, rope) = buffer.read_with(cx, |buffer, cx| {
1283                    let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?;
1284                    anyhow::Ok((project_entry_id, buffer.as_rope().clone()))
1285                })?;
1286                anyhow::Ok((project_entry_id, rope))
1287            });
1288            // Build a string from the rope on a background thread.
1289            cx.background_spawn(async move {
1290                let (project_entry_id, rope) = rope_task.await?;
1291                anyhow::Ok(RulesFileContext {
1292                    path_in_worktree,
1293                    text: rope.to_string().trim().to_string(),
1294                    project_entry_id: project_entry_id.to_usize(),
1295                })
1296            })
1297        })
1298    }
1299
1300    fn handle_thread_title_updated(
1301        &mut self,
1302        thread: Entity<Thread>,
1303        _: &TitleUpdated,
1304        cx: &mut Context<Self>,
1305    ) {
1306        let session_id = thread.read(cx).id();
1307        let Some(session) = self.sessions.get(session_id) else {
1308            return;
1309        };
1310
1311        let thread = thread.downgrade();
1312        let acp_thread = session.acp_thread.downgrade();
1313        cx.spawn(async move |_, cx| {
1314            let title = thread.read_with(cx, |thread, _| thread.title())?;
1315            if let Some(title) = title {
1316                let task =
1317                    acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
1318                task.await?;
1319            }
1320            anyhow::Ok(())
1321        })
1322        .detach_and_log_err(cx);
1323    }
1324
1325    fn handle_thread_token_usage_updated(
1326        &mut self,
1327        thread: Entity<Thread>,
1328        usage: &TokenUsageUpdated,
1329        cx: &mut Context<Self>,
1330    ) {
1331        let Some(session) = self.sessions.get(thread.read(cx).id()) else {
1332            return;
1333        };
1334        session.acp_thread.update(cx, |acp_thread, cx| {
1335            acp_thread.update_token_usage(usage.0.clone(), cx);
1336        });
1337    }
1338
1339    fn handle_project_event(
1340        &mut self,
1341        project: Entity<Project>,
1342        event: &project::Event,
1343        _cx: &mut Context<Self>,
1344    ) {
1345        let project_id = project.entity_id();
1346        let Some(state) = self.projects.get_mut(&project_id) else {
1347            return;
1348        };
1349        match event {
1350            project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
1351                state.project_context_needs_refresh.send(()).ok();
1352            }
1353            project::Event::WorktreeUpdatedEntries(_, items) => {
1354                if items.iter().any(|(path, _, _)| {
1355                    let path_ref = path.as_ref();
1356                    RULES_FILE_REL_PATHS
1357                        .iter()
1358                        .any(|rules_path| path_ref == rules_path.as_ref())
1359                        || AGENTS_PREFIX
1360                            .as_ref()
1361                            .is_some_and(|prefix| path_ref.starts_with(prefix))
1362                }) {
1363                    state.project_context_needs_refresh.send(()).ok();
1364                }
1365            }
1366            _ => {}
1367        }
1368    }
1369
1370    fn handle_models_updated_event(
1371        &mut self,
1372        _registry: Entity<LanguageModelRegistry>,
1373        event: &language_model::Event,
1374        cx: &mut Context<Self>,
1375    ) {
1376        self.models.refresh_list(cx);
1377
1378        let registry = LanguageModelRegistry::read_global(cx);
1379        let default_model = registry.default_model().map(|m| m.model);
1380        let summarization_model = registry.thread_summary_model(cx).map(|m| m.model);
1381
1382        for session in self.sessions.values_mut() {
1383            session.thread.update(cx, |thread, cx| {
1384                thread.ensure_model(default_model.as_ref(), cx);
1385
1386                if let Some(model) = summarization_model.clone() {
1387                    if thread.summarization_model().is_none()
1388                        || matches!(event, language_model::Event::ThreadSummaryModelChanged)
1389                    {
1390                        thread.set_summarization_model(Some(model), cx);
1391                    }
1392                }
1393            });
1394        }
1395    }
1396
1397    fn handle_context_server_store_updated(
1398        &mut self,
1399        store: Entity<project::context_server_store::ContextServerStore>,
1400        _event: &project::context_server_store::ServerStatusChangedEvent,
1401        cx: &mut Context<Self>,
1402    ) {
1403        let project_id = self.projects.iter().find_map(|(id, state)| {
1404            if *state.context_server_registry.read(cx).server_store() == store {
1405                Some(*id)
1406            } else {
1407                None
1408            }
1409        });
1410        if let Some(project_id) = project_id {
1411            self.update_available_commands_for_project(project_id, cx);
1412        }
1413    }
1414
1415    fn handle_context_server_registry_event(
1416        &mut self,
1417        registry: Entity<ContextServerRegistry>,
1418        event: &ContextServerRegistryEvent,
1419        cx: &mut Context<Self>,
1420    ) {
1421        match event {
1422            ContextServerRegistryEvent::ToolsChanged => {}
1423            ContextServerRegistryEvent::PromptsChanged => {
1424                let project_id = self.projects.iter().find_map(|(id, state)| {
1425                    if state.context_server_registry == registry {
1426                        Some(*id)
1427                    } else {
1428                        None
1429                    }
1430                });
1431                if let Some(project_id) = project_id {
1432                    self.update_available_commands_for_project(project_id, cx);
1433                }
1434            }
1435        }
1436    }
1437
1438    fn publish_skill_index(&self, cx: &mut Context<Self>) {
1439        let mut global_skills = Vec::new();
1440        let mut project_groups: Vec<ProjectSkillGroup> = Vec::new();
1441        let mut seen_global = false;
1442
1443        for state in self.projects.values() {
1444            for skill in state.skills.iter() {
1445                match &skill.source {
1446                    SkillSource::BuiltIn => {}
1447                    SkillSource::Global => {
1448                        if !seen_global {
1449                            global_skills.push(skill.clone());
1450                        }
1451                    }
1452                    SkillSource::ProjectLocal {
1453                        worktree_id,
1454                        worktree_root_name,
1455                    } => {
1456                        if let Some(group) = project_groups
1457                            .iter_mut()
1458                            .find(|g| g.worktree_id == *worktree_id)
1459                        {
1460                            group.skills.push(skill.clone());
1461                        } else {
1462                            project_groups.push(ProjectSkillGroup {
1463                                worktree_id: *worktree_id,
1464                                worktree_root_name: SharedString::from(worktree_root_name.clone()),
1465                                skills: vec![skill.clone()],
1466                            });
1467                        }
1468                    }
1469                }
1470            }
1471            if !global_skills.is_empty() {
1472                seen_global = true;
1473            }
1474        }
1475
1476        cx.set_global(SkillIndex {
1477            global_skills,
1478            project_skills: project_groups,
1479        });
1480    }
1481
1482    fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context<Self>) {
1483        let available_commands =
1484            Self::build_available_commands_for_project(self.projects.get(&project_id), cx);
1485        for session in self.sessions.values() {
1486            if session.project_id != project_id {
1487                continue;
1488            }
1489            session.acp_thread.update(cx, |thread, cx| {
1490                thread
1491                    .handle_session_update(
1492                        acp::SessionUpdate::AvailableCommandsUpdate(
1493                            acp::AvailableCommandsUpdate::new(available_commands.clone()),
1494                        ),
1495                        cx,
1496                    )
1497                    .log_err();
1498            });
1499        }
1500    }
1501
1502    fn build_available_commands_for_project(
1503        project_state: Option<&ProjectState>,
1504        cx: &App,
1505    ) -> Vec<acp::AvailableCommand> {
1506        let Some(state) = project_state else {
1507            return Vec::new();
1508        };
1509        let compact_command = acp::AvailableCommand::new(
1510            COMPACT_COMMAND_NAME,
1511            "Summarize the conversation so far to free up context",
1512        )
1513        .meta(acp_thread::meta_with_command_category(
1514            acp_thread::CommandCategory::Native,
1515        ));
1516
1517        let registry = state.context_server_registry.read(cx);
1518
1519        // Reserve the built-in command name so a same-named MCP prompt is
1520        // force-prefixed (`/<server>.compact`) and stays reachable: an
1521        // unqualified `/compact` always routes to the native command.
1522        let ambiguous_prompt_names = ambiguous_mcp_prompt_names(
1523            [COMPACT_COMMAND_NAME],
1524            registry.prompts().map(|p| p.prompt.name.as_str()),
1525        );
1526
1527        let mcp_commands = registry.prompts().flat_map(|context_server_prompt| {
1528            let prompt = &context_server_prompt.prompt;
1529
1530            let should_prefix = ambiguous_prompt_names.contains(prompt.name.as_str());
1531
1532            let name = if should_prefix {
1533                format!("{}.{}", context_server_prompt.server_id, prompt.name)
1534            } else {
1535                prompt.name.clone()
1536            };
1537
1538            let mut command =
1539                acp::AvailableCommand::new(name, prompt.description.clone().unwrap_or_default())
1540                    .meta(acp_thread::meta_with_command_category(
1541                        acp_thread::CommandCategory::Mcp,
1542                    ));
1543
1544            match prompt.arguments.as_deref() {
1545                Some([arg]) => {
1546                    let hint = format!("<{}>", arg.name);
1547
1548                    command = command.input(acp::AvailableCommandInput::Unstructured(
1549                        acp::UnstructuredCommandInput::new(hint),
1550                    ));
1551                }
1552                Some([]) | None => {}
1553                Some(_) => {
1554                    // skip >1 argument commands since we don't support them yet
1555                    return None;
1556                }
1557            }
1558
1559            Some(command)
1560        });
1561
1562        std::iter::once(compact_command)
1563            .chain(mcp_commands)
1564            .collect()
1565    }
1566
1567    pub fn load_thread(
1568        &mut self,
1569        id: acp::SessionId,
1570        project: Entity<Project>,
1571        cx: &mut Context<Self>,
1572    ) -> Task<Result<Entity<Thread>>> {
1573        let database_future = ThreadsDatabase::connect(cx);
1574        cx.spawn(async move |this, cx| {
1575            let database = database_future.await.map_err(|err| anyhow!(err))?;
1576            let db_thread = database
1577                .load_thread(id.clone())
1578                .await?
1579                .with_context(|| format!("no thread found with ID: {id:?}"))?;
1580
1581            this.update(cx, |this, cx| {
1582                let project_id = this.get_or_create_project_state(&project, cx);
1583                let project_state = this
1584                    .projects
1585                    .get(&project_id)
1586                    .context("project state not found")?;
1587                let summarization_model = LanguageModelRegistry::read_global(cx)
1588                    .thread_summary_model(cx)
1589                    .map(|c| c.model);
1590
1591                Ok(cx.new(|cx| {
1592                    let mut thread = Thread::from_db(
1593                        id.clone(),
1594                        db_thread,
1595                        project_state.project.clone(),
1596                        project_state.project_context.clone(),
1597                        project_state.context_server_registry.clone(),
1598                        this.templates.clone(),
1599                        cx,
1600                    );
1601                    thread.set_summarization_model(summarization_model, cx);
1602                    thread
1603                }))
1604            })?
1605        })
1606    }
1607
1608    pub fn open_thread(
1609        &mut self,
1610        id: acp::SessionId,
1611        project: Entity<Project>,
1612        cx: &mut Context<Self>,
1613    ) -> Task<Result<Entity<AcpThread>>> {
1614        if let Some(session) = self.sessions.get_mut(&id) {
1615            session.ref_count += 1;
1616            return Task::ready(Ok(session.acp_thread.clone()));
1617        }
1618
1619        if let Some(pending) = self.pending_sessions.get_mut(&id) {
1620            pending.ref_count += 1;
1621            let task = pending.task.clone();
1622            return cx.background_spawn(async move { task.await.map_err(|err| anyhow!(err)) });
1623        }
1624
1625        let task = self.load_thread(id.clone(), project.clone(), cx);
1626        let shared_task = cx
1627            .spawn({
1628                let id = id.clone();
1629                async move |this, cx| {
1630                    let thread = match task.await {
1631                        Ok(thread) => thread,
1632                        Err(err) => {
1633                            this.update(cx, |this, _cx| {
1634                                this.pending_sessions.remove(&id);
1635                            })
1636                            .ok();
1637                            return Err(Arc::new(err));
1638                        }
1639                    };
1640                    let acp_thread = this
1641                        .update(cx, |this, cx| {
1642                            let project_id = this.get_or_create_project_state(&project, cx);
1643                            let ref_count = this
1644                                .pending_sessions
1645                                .remove(&id)
1646                                .map_or(1, |pending| pending.ref_count);
1647                            this.register_session(thread.clone(), project_id, ref_count, cx)
1648                        })
1649                        .map_err(Arc::new)?;
1650                    let events = thread.update(cx, |thread, cx| thread.replay(cx));
1651                    cx.update(|cx| {
1652                        NativeAgentConnection::handle_thread_events(
1653                            events,
1654                            acp_thread.downgrade(),
1655                            None,
1656                            cx,
1657                        )
1658                    })
1659                    .await
1660                    .map_err(Arc::new)?;
1661                    acp_thread.update(cx, |thread, cx| {
1662                        thread.snapshot_completed_plan(cx);
1663                    });
1664                    Ok(acp_thread)
1665                }
1666            })
1667            .shared();
1668        self.pending_sessions.insert(
1669            id,
1670            PendingSession {
1671                task: shared_task.clone(),
1672                ref_count: 1,
1673            },
1674        );
1675
1676        cx.background_spawn(async move { shared_task.await.map_err(|err| anyhow!(err)) })
1677    }
1678
1679    pub fn thread_summary(
1680        &mut self,
1681        id: acp::SessionId,
1682        project: Entity<Project>,
1683        cx: &mut Context<Self>,
1684    ) -> Task<Result<SharedString>> {
1685        let thread = self.open_thread(id.clone(), project, cx);
1686        cx.spawn(async move |this, cx| {
1687            let acp_thread = thread.await?;
1688            let result = this
1689                .update(cx, |this, cx| {
1690                    this.sessions
1691                        .get(&id)
1692                        .unwrap()
1693                        .thread
1694                        .update(cx, |thread, cx| thread.summary(cx))
1695                })?
1696                .await
1697                .context("Failed to generate summary")?;
1698
1699            this.update(cx, |this, cx| this.close_session(&id, cx))?
1700                .await?;
1701            drop(acp_thread);
1702            Ok(result)
1703        })
1704    }
1705
1706    fn close_session(
1707        &mut self,
1708        session_id: &acp::SessionId,
1709        cx: &mut Context<Self>,
1710    ) -> Task<Result<()>> {
1711        let Some(session) = self.sessions.get_mut(session_id) else {
1712            return Task::ready(Ok(()));
1713        };
1714
1715        session.ref_count -= 1;
1716        if session.ref_count > 0 {
1717            return Task::ready(Ok(()));
1718        }
1719
1720        let thread = session.thread.clone();
1721        self.save_thread(thread, cx);
1722        let Some(session) = self.sessions.remove(session_id) else {
1723            return Task::ready(Ok(()));
1724        };
1725        let project_id = session.project_id;
1726
1727        let has_remaining = self.sessions.values().any(|s| s.project_id == project_id);
1728        if !has_remaining {
1729            self.projects.remove(&project_id);
1730            self.publish_skill_index(cx);
1731        }
1732
1733        session.pending_save
1734    }
1735
1736    fn save_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) {
1737        let id = thread.read(cx).id().clone();
1738        let Some(session) = self.sessions.get(&id) else {
1739            return;
1740        };
1741        let Some((id, folder_paths, db_thread)) = self.thread_save_payload(session, cx) else {
1742            return;
1743        };
1744
1745        let database_future = ThreadsDatabase::connect(cx);
1746        let thread_store = self.thread_store.clone();
1747        let Some(session) = self.sessions.get_mut(&id) else {
1748            return;
1749        };
1750        session.pending_save = cx.spawn(async move |_, cx| {
1751            let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else {
1752                return Ok(());
1753            };
1754            let db_thread = db_thread.await;
1755            database
1756                .save_thread(id, db_thread, folder_paths)
1757                .await
1758                .log_err();
1759            thread_store.update(cx, |store, cx| store.reload(cx));
1760            Ok(())
1761        });
1762    }
1763
1764    /// Builds everything needed to persist a session's thread content,
1765    /// capturing the current draft prompt from the ACP thread. Returns `None`
1766    /// if the thread is empty or its project state is gone.
1767    fn thread_save_payload(
1768        &self,
1769        session: &Session,
1770        cx: &mut App,
1771    ) -> Option<(acp::SessionId, PathList, Task<DbThread>)> {
1772        if session.thread.read(cx).is_empty() {
1773            return None;
1774        }
1775        let state = self.projects.get(&session.project_id)?;
1776        let folder_paths = PathList::new(
1777            &state
1778                .project
1779                .read(cx)
1780                .visible_worktrees(cx)
1781                .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
1782                .collect::<Vec<_>>(),
1783        );
1784        let draft_prompt = session.acp_thread.read(cx).draft_prompt().map(Vec::from);
1785        let id = session.thread.read(cx).id().clone();
1786        let db_thread = session.thread.update(cx, |thread, cx| {
1787            thread.set_draft_prompt(draft_prompt);
1788            thread.to_db(cx)
1789        });
1790        Some((id, folder_paths, db_thread))
1791    }
1792
1793    /// Commits every non-empty thread's content on shutdown so the async
1794    /// `save_thread` losing the race can't leave metadata without content.
1795    fn flush_threads_on_quit(
1796        &mut self,
1797        cx: &mut Context<Self>,
1798    ) -> impl Future<Output = ()> + use<> {
1799        let database_future = ThreadsDatabase::connect(cx);
1800
1801        let mut saves = Vec::new();
1802        for session in self.sessions.values() {
1803            saves.extend(self.thread_save_payload(session, cx));
1804        }
1805
1806        async move {
1807            let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else {
1808                return;
1809            };
1810            // All quit observers share `gpui::SHUTDOWN_TIMEOUT`, so run the
1811            // saves concurrently instead of one at a time.
1812            future::join_all(saves.into_iter().map(|(id, folder_paths, db_thread)| {
1813                let database = database.clone();
1814                async move {
1815                    let db_thread = db_thread.await;
1816                    database
1817                        .save_thread(id, db_thread, folder_paths)
1818                        .await
1819                        .log_err();
1820                }
1821            }))
1822            .await;
1823        }
1824    }
1825
1826    fn send_mcp_prompt(
1827        &self,
1828        client_user_message_id: ClientUserMessageId,
1829        session_id: acp::SessionId,
1830        prompt_name: String,
1831        server_id: ContextServerId,
1832        arguments: HashMap<String, String>,
1833        original_content: Vec<acp::ContentBlock>,
1834        cx: &mut Context<Self>,
1835    ) -> Task<Result<acp::PromptResponse>> {
1836        let Some(state) = self.session_project_state(&session_id) else {
1837            return Task::ready(Err(anyhow!("Project state not found for session")));
1838        };
1839        let server_store = state
1840            .context_server_registry
1841            .read(cx)
1842            .server_store()
1843            .clone();
1844        let path_style = state.project.read(cx).path_style(cx);
1845
1846        cx.spawn(async move |this, cx| {
1847            let prompt =
1848                crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
1849
1850            let (acp_thread, thread) = this.update(cx, |this, _cx| {
1851                let session = this
1852                    .sessions
1853                    .get(&session_id)
1854                    .context("Failed to get session")?;
1855                anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
1856            })??;
1857
1858            let mut last_is_user = true;
1859
1860            thread.update(cx, |thread, cx| {
1861                thread.push_acp_user_block(
1862                    client_user_message_id,
1863                    original_content.into_iter().skip(1),
1864                    path_style,
1865                    cx,
1866                );
1867            });
1868
1869            for message in prompt.messages {
1870                let context_server::types::PromptMessage { role, content } = message;
1871                let block = mcp_message_content_to_acp_content_block(content);
1872
1873                match role {
1874                    context_server::types::Role::User => {
1875                        let id = acp_thread::ClientUserMessageId::new();
1876
1877                        acp_thread.update(cx, |acp_thread, cx| {
1878                            acp_thread.push_user_content_block_with_indent(
1879                                Some(id.clone()),
1880                                block.clone(),
1881                                true,
1882                                cx,
1883                            );
1884                        });
1885
1886                        thread.update(cx, |thread, cx| {
1887                            thread.push_acp_user_block(id, [block], path_style, cx);
1888                        });
1889                    }
1890                    context_server::types::Role::Assistant => {
1891                        acp_thread.update(cx, |acp_thread, cx| {
1892                            acp_thread.push_assistant_content_block_with_indent(
1893                                block.clone(),
1894                                false,
1895                                true,
1896                                cx,
1897                            );
1898                        });
1899
1900                        thread.update(cx, |thread, cx| {
1901                            thread.push_acp_agent_block(block, cx);
1902                        });
1903                    }
1904                }
1905
1906                last_is_user = role == context_server::types::Role::User;
1907            }
1908
1909            let response_stream = thread.update(cx, |thread, cx| {
1910                if last_is_user {
1911                    thread.send_existing(cx)
1912                } else {
1913                    // Resume if MCP prompt did not end with a user message
1914                    thread.resume(cx)
1915                }
1916            })?;
1917
1918            let connection = this.upgrade().map(NativeAgentConnection);
1919            cx.update(|cx| {
1920                NativeAgentConnection::handle_thread_events(
1921                    response_stream,
1922                    acp_thread.downgrade(),
1923                    connection,
1924                    cx,
1925                )
1926            })
1927            .await
1928        })
1929    }
1930
1931    /// Run a summary-based context compaction in response to the built-in
1932    /// `/compact` slash command.
1933    fn send_compact_command(
1934        &self,
1935        client_user_message_id: ClientUserMessageId,
1936        session_id: acp::SessionId,
1937        cx: &mut Context<Self>,
1938    ) -> Task<Result<acp::PromptResponse>> {
1939        cx.spawn(async move |this, cx| {
1940            let (acp_thread, thread) = this.update(cx, |this, _cx| {
1941                let session = this
1942                    .sessions
1943                    .get(&session_id)
1944                    .context("Failed to get session")?;
1945                anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
1946            })??;
1947
1948            let response_stream =
1949                thread.update(cx, |thread, cx| thread.compact(client_user_message_id, cx))?;
1950            acp_thread.update(cx, |acp_thread, cx| {
1951                acp_thread.update_token_usage(None, cx);
1952            });
1953
1954            let connection = this.upgrade().map(NativeAgentConnection);
1955            cx.update(|cx| {
1956                NativeAgentConnection::handle_thread_events(
1957                    response_stream,
1958                    acp_thread.downgrade(),
1959                    connection,
1960                    cx,
1961                )
1962            })
1963            .await
1964        })
1965    }
1966
1967    /// Activate a skill in response to a `/skill-name` slash command. The
1968    /// skill body is wrapped in the same `<skill_content>` envelope the
1969    /// model-driven `skill` tool uses, so the conversation looks the same
1970    /// regardless of who initiated the load. Any text the user typed after
1971    /// the command on the same line — plus any additional content blocks
1972    /// they attached (file mentions, etc.) — is appended to the same user
1973    /// message after the skill envelope, so the model sees the skill
1974    /// instructions followed by the user's request.
1975    fn send_skill_invocation(
1976        &self,
1977        client_user_message_id: ClientUserMessageId,
1978        session_id: acp::SessionId,
1979        skill: Skill,
1980        original_content: Vec<acp::ContentBlock>,
1981        cx: &mut Context<Self>,
1982    ) -> Task<Result<acp::PromptResponse>> {
1983        let Some(state) = self.session_project_state(&session_id) else {
1984            return Task::ready(Err(anyhow!("Project state not found for session")));
1985        };
1986        let path_style = state.project.read(cx).path_style(cx);
1987        let read_skill_body =
1988            skill_body_resolver_for_project(state.project.clone(), self.fs.clone());
1989
1990        cx.spawn(async move |this, cx| {
1991            let (acp_thread, thread) = this.update(cx, |this, _cx| {
1992                let session = this
1993                    .sessions
1994                    .get(&session_id)
1995                    .context("Failed to get session")?;
1996                anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
1997            })??;
1998
1999            // Build the model-context message: skill envelope first, then
2000            // anything the user wrote after the slash command. The first
2001            // text block has its leading `/cmd` stripped so the literal
2002            // command name isn't echoed into the model's context, but any
2003            // text the user typed after it on the same line is preserved
2004            // verbatim and appended after the envelope.
2005            //
2006            // Read the body on demand here — bodies live on disk between
2007            // materializations to keep memory cost O(total frontmatter)
2008            // rather than O(total file size).
2009            let body = if let Some(embedded) = skill.embedded_body {
2010                embedded.to_string()
2011            } else {
2012                read_skill_body(skill.clone(), cx).await.with_context(|| {
2013                    format!(
2014                        "Failed to read skill body from {}",
2015                        skill.skill_file_path.display()
2016                    )
2017                })?
2018            };
2019            let envelope = crate::tools::render_skill_envelope(&skill, &body);
2020            let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope));
2021
2022            let mut user_blocks = original_content;
2023            if let Some(acp::ContentBlock::Text(text_content)) = user_blocks.first_mut() {
2024                let stripped = strip_slash_command_prefix(&text_content.text);
2025                if stripped.trim().is_empty() {
2026                    user_blocks.remove(0);
2027                } else {
2028                    text_content.text = stripped;
2029                }
2030            }
2031
2032            // UI: show the rendered envelope as a sibling user message so
2033            // the user can see what context was loaded for the skill. The
2034            // user's own typed message is already rendered by the normal
2035            // prompt flow, so we don't push it to the UI again here.
2036            let injected_id = acp_thread::ClientUserMessageId::new();
2037            acp_thread.update(cx, |acp_thread, cx| {
2038                acp_thread.push_user_content_block_with_indent(
2039                    Some(injected_id),
2040                    envelope_block.clone(),
2041                    true,
2042                    cx,
2043                );
2044            });
2045
2046            // Model context: a single user message containing the skill
2047            // envelope followed by the user's appended content.
2048            let mut combined = Vec::with_capacity(user_blocks.len() + 1);
2049            combined.push(envelope_block);
2050            combined.extend(user_blocks);
2051
2052            thread.update(cx, |thread, cx| {
2053                thread.push_acp_user_block(client_user_message_id, combined, path_style, cx);
2054            });
2055
2056            let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?;
2057
2058            let connection = this.upgrade().map(NativeAgentConnection);
2059            cx.update(|cx| {
2060                NativeAgentConnection::handle_thread_events(
2061                    response_stream,
2062                    acp_thread.downgrade(),
2063                    connection,
2064                    cx,
2065                )
2066            })
2067            .await
2068        })
2069    }
2070}
2071
2072/// Wrapper struct that implements the AgentConnection trait
2073#[derive(Clone)]
2074pub struct NativeAgentConnection(pub Entity<NativeAgent>);
2075
2076impl NativeAgentConnection {
2077    pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option<Entity<Thread>> {
2078        self.0
2079            .read(cx)
2080            .sessions
2081            .get(session_id)
2082            .map(|session| session.thread.clone())
2083    }
2084
2085    /// Forwards to [`NativeAgent::ensure_skills_scan_started`]. The
2086    /// agent panel calls this from its three user-interaction trigger
2087    /// points (input box focus, slash-autocomplete invocation, and
2088    /// conversation submit) so that the skills directory is observed
2089    /// only when the user is actually engaging with the panel.
2090    pub fn ensure_skills_scan_started(&self, cx: &mut App) {
2091        self.0
2092            .update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
2093    }
2094
2095    pub fn refresh_skills_for_project(&self, project: Entity<Project>, cx: &mut App) {
2096        self.0.update(cx, |agent, cx| {
2097            let project_id = agent.get_or_create_project_state(&project, cx);
2098            agent.ensure_skills_scan_started(cx);
2099            if let Some(state) = agent.projects.get_mut(&project_id) {
2100                state.project_context_needs_refresh.send(()).ok();
2101            }
2102        });
2103    }
2104
2105    pub fn available_skills(
2106        &self,
2107        session_id: &acp::SessionId,
2108        cx: &App,
2109    ) -> Vec<NativeAvailableSkill> {
2110        self.0
2111            .read(cx)
2112            .session_project_state(session_id)
2113            .map(|state| {
2114                state
2115                    .skills
2116                    .iter()
2117                    .map(NativeAvailableSkill::from)
2118                    .collect()
2119            })
2120            .unwrap_or_default()
2121    }
2122
2123    pub fn load_thread(
2124        &self,
2125        id: acp::SessionId,
2126        project: Entity<Project>,
2127        cx: &mut App,
2128    ) -> Task<Result<Entity<Thread>>> {
2129        self.0
2130            .update(cx, |this, cx| this.load_thread(id, project, cx))
2131    }
2132
2133    fn run_turn(
2134        &self,
2135        session_id: acp::SessionId,
2136        cx: &mut App,
2137        f: impl 'static
2138        + FnOnce(Entity<Thread>, &mut App) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>,
2139    ) -> Task<Result<acp::PromptResponse>> {
2140        let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| {
2141            agent
2142                .sessions
2143                .get_mut(&session_id)
2144                .map(|s| (s.thread.clone(), s.acp_thread.clone()))
2145        }) else {
2146            log::error!("Session not found in run_turn: {}", session_id);
2147            return Task::ready(Err(anyhow!("Session not found")));
2148        };
2149        log::debug!("Found session for: {}", session_id);
2150
2151        let response_stream = match f(thread, cx) {
2152            Ok(stream) => stream,
2153            Err(err) => return Task::ready(Err(err)),
2154        };
2155        Self::handle_thread_events(
2156            response_stream,
2157            acp_thread.downgrade(),
2158            Some(self.clone()),
2159            cx,
2160        )
2161    }
2162
2163    fn handle_thread_events(
2164        mut events: mpsc::UnboundedReceiver<Result<ThreadEvent>>,
2165        acp_thread: WeakEntity<AcpThread>,
2166        connection: Option<NativeAgentConnection>,
2167        cx: &App,
2168    ) -> Task<Result<acp::PromptResponse>> {
2169        cx.spawn(async move |cx| {
2170            // Handle response stream and forward to session.acp_thread
2171            while let Some(result) = events.next().await {
2172                match result {
2173                    Ok(event) => {
2174                        log::trace!("Received completion event: {:?}", event);
2175
2176                        match event {
2177                            ThreadEvent::UserMessage(message) => {
2178                                acp_thread.update(cx, |thread, cx| {
2179                                    for content in &*message.content {
2180                                        thread.push_user_content_block(
2181                                            Some(message.id.clone()),
2182                                            content.clone().into(),
2183                                            cx,
2184                                        );
2185                                    }
2186                                })?;
2187                            }
2188                            ThreadEvent::AgentText(text) => {
2189                                acp_thread.update(cx, |thread, cx| {
2190                                    thread.push_assistant_content_block(text.into(), false, cx)
2191                                })?;
2192                            }
2193                            ThreadEvent::AgentThinking(text) => {
2194                                acp_thread.update(cx, |thread, cx| {
2195                                    thread.push_assistant_content_block(text.into(), true, cx)
2196                                })?;
2197                            }
2198                            ThreadEvent::ToolCallAuthorization(ToolCallAuthorization {
2199                                tool_call,
2200                                options,
2201                                response,
2202                                context: _,
2203                                kind,
2204                            }) => {
2205                                let outcome_task = acp_thread.update(cx, |thread, cx| {
2206                                    thread.request_tool_call_authorization(
2207                                        tool_call, options, kind, cx,
2208                                    )
2209                                })??;
2210                                cx.background_spawn(async move {
2211                                    if let acp_thread::RequestPermissionOutcome::Selected(outcome) =
2212                                        outcome_task.await
2213                                    {
2214                                        response
2215                                            .send(outcome)
2216                                            .map_err(|_| {
2217                                                anyhow!("authorization receiver was dropped")
2218                                            })
2219                                            .log_err();
2220                                    }
2221                                })
2222                                .detach();
2223                            }
2224                            ThreadEvent::ToolCallAuthorizationResolved {
2225                                tool_call_id,
2226                                outcome,
2227                            } => {
2228                                acp_thread.update(cx, |thread, cx| {
2229                                    thread.authorize_tool_call(tool_call_id, outcome, cx);
2230                                })?;
2231                            }
2232                            ThreadEvent::ToolCall(tool_call) => {
2233                                acp_thread.update(cx, |thread, cx| {
2234                                    thread.upsert_tool_call(tool_call, cx)
2235                                })??;
2236                            }
2237                            ThreadEvent::ToolCallUpdate(update) => {
2238                                acp_thread.update(cx, |thread, cx| {
2239                                    thread.update_tool_call(update, cx)
2240                                })??;
2241                            }
2242                            ThreadEvent::SubagentSpawned(session_id) => {
2243                                acp_thread.update(cx, |thread, cx| {
2244                                    thread.subagent_spawned(session_id, cx);
2245                                })?;
2246                            }
2247                            ThreadEvent::Retry(status) => {
2248                                if acp_thread::refusal_fallback_model_from_meta(&status.meta)
2249                                    .is_some()
2250                                {
2251                                    if let Some(connection) = &connection {
2252                                        cx.update(|cx| {
2253                                            connection.0.update(cx, |agent, _| {
2254                                                agent.models.notify_model_selection_changed();
2255                                            });
2256                                        });
2257                                    }
2258                                }
2259                                acp_thread.update(cx, |thread, cx| {
2260                                    thread.update_retry_status(status, cx)
2261                                })?;
2262                            }
2263                            ThreadEvent::ContextCompaction(compaction) => {
2264                                acp_thread.update(cx, |thread, cx| {
2265                                    thread.push_context_compaction(compaction, cx);
2266                                })?;
2267                            }
2268                            ThreadEvent::ContextCompactionUpdate(update) => {
2269                                acp_thread.update(cx, |thread, cx| {
2270                                    thread.update_context_compaction(update, cx);
2271                                })?;
2272                            }
2273                            ThreadEvent::Stop(stop_reason) => {
2274                                log::debug!("Assistant message complete: {:?}", stop_reason);
2275                                return Ok(acp::PromptResponse::new(stop_reason));
2276                            }
2277                        }
2278                    }
2279                    Err(e) => {
2280                        log::error!("Error in model response stream: {:?}", e);
2281                        return Err(e);
2282                    }
2283                }
2284            }
2285
2286            log::debug!("Response stream completed");
2287            anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
2288        })
2289    }
2290}
2291
2292struct Command<'a> {
2293    prompt_name: &'a str,
2294    arg_value: &'a str,
2295    /// MCP server prefix from `/<server>.<prompt>` syntax. Mutually
2296    /// exclusive with `skill_scope` — the two grammars use different
2297    /// delimiters (`.` for MCP, `:` for skill scopes) so they can't
2298    /// collide.
2299    explicit_server_id: Option<&'a str>,
2300    /// Skill scope qualifier from `/<scope>:<name>` syntax, where
2301    /// `<scope>` is either the literal `global` or a worktree root
2302    /// name. The `:` separator namespaces these against MCP server
2303    /// prefixes (which use `.`) so an MCP server literally named
2304    /// `global` or named after a worktree still parses unambiguously.
2305    skill_scope: Option<&'a str>,
2306}
2307
2308impl<'a> Command<'a> {
2309    fn is_unqualified(&self, prompt_name: &str) -> bool {
2310        self.prompt_name == prompt_name
2311            && self.explicit_server_id.is_none()
2312            && self.skill_scope.is_none()
2313    }
2314
2315    fn parse(prompt: &'a [acp::ContentBlock]) -> Option<Self> {
2316        let acp::ContentBlock::Text(text_content) = prompt.first()? else {
2317            return None;
2318        };
2319        let text = text_content.text.trim();
2320        let command = text.strip_prefix('/')?;
2321        let (command, arg_value) = command
2322            .split_once(char::is_whitespace)
2323            .unwrap_or((command, ""));
2324
2325        // Skill scope qualifier: `/<scope>:<name>`. Checked before the
2326        // MCP `.` grammar because `:` and `.` are different delimiters
2327        // — the two namespaces can't collide. Skill names are
2328        // restricted to `[a-z0-9-]+` (no colons), so the LAST `:` is
2329        // always the scope/name boundary; using `rsplit_once` lets
2330        // scope labels (e.g. a worktree root name) themselves contain
2331        // colons without breaking the parse.
2332        //
2333        // An empty scope (`/:<name>`) is the qualified form for a
2334        // global skill — see `SkillSource::scope_prefix`. The name
2335        // must be non-empty for the colon to be meaningful.
2336        if let Some((scope, prompt_name)) = command.rsplit_once(':')
2337            && !prompt_name.is_empty()
2338        {
2339            return Some(Self {
2340                prompt_name,
2341                arg_value,
2342                explicit_server_id: None,
2343                skill_scope: Some(scope),
2344            });
2345        }
2346
2347        if let Some((server_id, prompt_name)) = command.split_once('.') {
2348            Some(Self {
2349                prompt_name,
2350                arg_value,
2351                explicit_server_id: Some(server_id),
2352                skill_scope: None,
2353            })
2354        } else {
2355            Some(Self {
2356                prompt_name: command,
2357                arg_value,
2358                explicit_server_id: None,
2359                skill_scope: None,
2360            })
2361        }
2362    }
2363}
2364
2365/// Strip a leading `/cmd` slash command from the start of a text block,
2366/// returning whatever text comes after it. Mirrors the parsing in
2367/// [`Command::parse`]: leading whitespace is ignored when locating the `/`,
2368/// then everything up to (and including) the first whitespace inside the
2369/// stripped text is dropped. The remainder is preserved verbatim — including
2370/// any embedded newlines — because users may format their continuation
2371/// intentionally.
2372///
2373/// If the input doesn't begin with `/`, it is returned unchanged so callers
2374/// degrade gracefully rather than silently mangling unrelated text.
2375fn strip_slash_command_prefix(text: &str) -> String {
2376    let trimmed_start = text.trim_start();
2377    let Some(rest) = trimmed_start.strip_prefix('/') else {
2378        return text.to_string();
2379    };
2380    rest.split_once(char::is_whitespace)
2381        .map(|(_, after)| after.to_string())
2382        .unwrap_or_default()
2383}
2384
2385struct NativeAgentModelSelector {
2386    session_id: acp::SessionId,
2387    connection: NativeAgentConnection,
2388}
2389
2390impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
2391    fn list_models(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelList>> {
2392        log::debug!("NativeAgentConnection::list_models called");
2393        let list = self.connection.0.read(cx).models.model_list.clone();
2394        Task::ready(if list.is_empty() {
2395            Err(anyhow::anyhow!("No models available"))
2396        } else {
2397            Ok(list)
2398        })
2399    }
2400
2401    fn select_model(&self, model_id: AgentModelId, cx: &mut App) -> Task<Result<()>> {
2402        log::debug!(
2403            "Setting model for session {}: {}",
2404            self.session_id,
2405            model_id
2406        );
2407        let Some(thread) = self
2408            .connection
2409            .0
2410            .read(cx)
2411            .sessions
2412            .get(&self.session_id)
2413            .map(|session| session.thread.clone())
2414        else {
2415            return Task::ready(Err(anyhow!("Session not found")));
2416        };
2417
2418        let Some(model) = self.connection.0.read(cx).models.model_from_id(&model_id) else {
2419            return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
2420        };
2421
2422        let favorite = agent_settings::AgentSettings::get_global(cx)
2423            .favorite_models
2424            .iter()
2425            .find(|favorite| {
2426                favorite.provider.0 == model.provider_id().0.as_ref()
2427                    && favorite.model == model.id().0.as_ref()
2428            })
2429            .cloned();
2430
2431        let LanguageModelSelection {
2432            enable_thinking,
2433            effort,
2434            speed,
2435            ..
2436        } = agent_settings::language_model_to_selection(&model, favorite.as_ref());
2437
2438        thread.update(cx, |thread, cx| {
2439            thread.set_model(model.clone(), cx);
2440            thread.set_thinking_effort(effort.clone(), cx);
2441            thread.set_thinking_enabled(enable_thinking, cx);
2442            if let Some(speed) = speed {
2443                thread.set_speed(speed, cx);
2444            }
2445        });
2446
2447        update_settings_file(
2448            self.connection.0.read(cx).fs.clone(),
2449            cx,
2450            move |settings, cx| {
2451                let provider = model.provider_id().0.to_string();
2452                let model = model.id().0.to_string();
2453                let enable_thinking = thread.read(cx).thinking_enabled();
2454                let speed = thread.read(cx).speed();
2455                settings
2456                    .agent
2457                    .get_or_insert_default()
2458                    .set_model(LanguageModelSelection {
2459                        provider: provider.into(),
2460                        model,
2461                        enable_thinking,
2462                        effort,
2463                        speed,
2464                    });
2465            },
2466        );
2467
2468        Task::ready(Ok(()))
2469    }
2470
2471    fn selected_model(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelInfo>> {
2472        let Some(thread) = self
2473            .connection
2474            .0
2475            .read(cx)
2476            .sessions
2477            .get(&self.session_id)
2478            .map(|session| session.thread.clone())
2479        else {
2480            return Task::ready(Err(anyhow!("Session not found")));
2481        };
2482        let Some(model) = thread.read(cx).model() else {
2483            return Task::ready(Err(anyhow!("Model not found")));
2484        };
2485        let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id())
2486        else {
2487            return Task::ready(Err(anyhow!("Provider not found")));
2488        };
2489        Task::ready(Ok(LanguageModels::map_language_model_to_info(
2490            model, &provider,
2491        )))
2492    }
2493
2494    fn favorite_model_ids(&self, cx: &mut App) -> HashSet<AgentModelId> {
2495        agent_settings::AgentSettings::get_global(cx)
2496            .favorite_model_ids()
2497            .into_iter()
2498            .map(AgentModelId::from)
2499            .collect()
2500    }
2501
2502    fn toggle_favorite_model(&self, model_id: AgentModelId, should_be_favorite: bool, cx: &App) {
2503        let selection = model_id_to_selection(&model_id, cx);
2504        let fs = self.connection.0.read(cx).fs.clone();
2505        update_settings_file(fs, cx, move |settings, _| {
2506            let agent = settings.agent.get_or_insert_default();
2507            if should_be_favorite {
2508                agent.add_favorite_model(selection.clone());
2509            } else {
2510                agent.remove_favorite_model(&selection);
2511            }
2512        });
2513    }
2514
2515    fn watch(&self, cx: &mut App) -> Option<watch::Receiver<()>> {
2516        Some(self.connection.0.read(cx).models.watch())
2517    }
2518
2519    fn should_render_footer(&self) -> bool {
2520        true
2521    }
2522}
2523
2524fn model_id_to_selection(model_id: &AgentModelId, cx: &App) -> LanguageModelSelection {
2525    let id = model_id.as_ref();
2526    let (provider, model) = id.split_once('/').unwrap_or(("", id));
2527
2528    let provider_id = LanguageModelProviderId(provider.to_string().into());
2529    let model_id = LanguageModelId(model.to_string().into());
2530    let resolved = LanguageModelRegistry::global(cx)
2531        .read(cx)
2532        .provider(&provider_id)
2533        .and_then(|provider| {
2534            provider
2535                .provided_models(cx)
2536                .into_iter()
2537                .find(|model| model.id() == model_id)
2538        });
2539
2540    let Some(resolved) = resolved else {
2541        return LanguageModelSelection {
2542            provider: provider.to_owned().into(),
2543            model: model.to_owned(),
2544            enable_thinking: false,
2545            effort: None,
2546            speed: None,
2547        };
2548    };
2549
2550    let current_user_selection = agent_settings::AgentSettings::get_global(cx)
2551        .default_model
2552        .as_ref()
2553        .filter(|selection| {
2554            selection.provider.0 == resolved.provider_id().0.as_ref()
2555                && selection.model == resolved.id().0.as_ref()
2556        })
2557        .cloned();
2558
2559    agent_settings::language_model_to_selection(&resolved, current_user_selection.as_ref())
2560}
2561
2562pub static OMEGA_AGENT_ID: LazyLock<AgentId> = LazyLock::new(|| AgentId::new("Omega Agent"));
2563
2564impl acp_thread::AgentConnection for NativeAgentConnection {
2565    fn agent_id(&self) -> AgentId {
2566        OMEGA_AGENT_ID.clone()
2567    }
2568
2569    fn telemetry_id(&self) -> SharedString {
2570        "zed".into()
2571    }
2572
2573    fn new_session(
2574        self: Rc<Self>,
2575        project: Entity<Project>,
2576        work_dirs: PathList,
2577        cx: &mut App,
2578    ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
2579        log::debug!("Creating new thread for project at: {work_dirs:?}");
2580        Task::ready(Ok(self
2581            .0
2582            .update(cx, |agent, cx| agent.new_session(project, cx))))
2583    }
2584
2585    fn supports_load_session(&self) -> bool {
2586        true
2587    }
2588
2589    fn load_session(
2590        self: Rc<Self>,
2591        session_id: acp::SessionId,
2592        project: Entity<Project>,
2593        _work_dirs: PathList,
2594        _title: Option<SharedString>,
2595        cx: &mut App,
2596    ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
2597        self.0
2598            .update(cx, |agent, cx| agent.open_thread(session_id, project, cx))
2599    }
2600
2601    fn supports_close_session(&self) -> bool {
2602        true
2603    }
2604
2605    fn close_session(
2606        self: Rc<Self>,
2607        session_id: &acp::SessionId,
2608        cx: &mut App,
2609    ) -> Task<Result<()>> {
2610        self.0
2611            .update(cx, |agent, cx| agent.close_session(session_id, cx))
2612    }
2613
2614    fn auth_methods(&self) -> &[acp::AuthMethod] {
2615        &[] // No auth for in-process
2616    }
2617
2618    fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
2619        Task::ready(Ok(()))
2620    }
2621
2622    fn model_selector(&self, session_id: &acp::SessionId) -> Option<Rc<dyn AgentModelSelector>> {
2623        Some(Rc::new(NativeAgentModelSelector {
2624            session_id: session_id.clone(),
2625            connection: self.clone(),
2626        }) as Rc<dyn AgentModelSelector>)
2627    }
2628
2629    fn client_user_message_ids(
2630        &self,
2631        _cx: &App,
2632    ) -> Option<Rc<dyn acp_thread::AgentSessionClientUserMessageIds>> {
2633        let prompt: Rc<dyn acp_thread::AgentSessionClientUserMessageIds> = Rc::new(self.clone());
2634        Some(prompt)
2635    }
2636
2637    fn prompt(
2638        &self,
2639        params: acp::PromptRequest,
2640        cx: &mut App,
2641    ) -> Task<Result<acp::PromptResponse>> {
2642        acp_thread::AgentSessionClientUserMessageIds::prompt(
2643            self,
2644            acp_thread::AgentSessionClientUserMessageIds::new_id(self),
2645            params,
2646            cx,
2647        )
2648    }
2649
2650    fn retry(
2651        &self,
2652        session_id: &acp::SessionId,
2653        _cx: &App,
2654    ) -> Option<Rc<dyn acp_thread::AgentSessionRetry>> {
2655        Some(Rc::new(NativeAgentSessionRetry {
2656            connection: self.clone(),
2657            session_id: session_id.clone(),
2658        }) as _)
2659    }
2660
2661    fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
2662        log::info!("Cancelling on session: {}", session_id);
2663        self.0.update(cx, |agent, cx| {
2664            if let Some(session) = agent.sessions.get(session_id) {
2665                session
2666                    .thread
2667                    .update(cx, |thread, cx| thread.cancel(cx))
2668                    .detach();
2669            }
2670        });
2671    }
2672
2673    fn truncate(
2674        &self,
2675        session_id: &acp::SessionId,
2676        cx: &App,
2677    ) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
2678        self.0.read_with(cx, |agent, _cx| {
2679            agent.sessions.get(session_id).map(|session| {
2680                Rc::new(NativeAgentSessionTruncate {
2681                    thread: session.thread.clone(),
2682                    acp_thread: session.acp_thread.downgrade(),
2683                }) as _
2684            })
2685        })
2686    }
2687
2688    fn set_title(
2689        &self,
2690        session_id: &acp::SessionId,
2691        cx: &App,
2692    ) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> {
2693        self.0.read_with(cx, |agent, _cx| {
2694            agent
2695                .sessions
2696                .get(session_id)
2697                .filter(|s| !s.thread.read(cx).is_subagent())
2698                .map(|session| {
2699                    Rc::new(NativeAgentSessionSetTitle {
2700                        thread: session.thread.clone(),
2701                    }) as _
2702                })
2703        })
2704    }
2705
2706    fn session_list(&self, cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
2707        let thread_store = self.0.read(cx).thread_store.clone();
2708        Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _)
2709    }
2710
2711    fn telemetry(&self) -> Option<Rc<dyn acp_thread::AgentTelemetry>> {
2712        Some(Rc::new(self.clone()) as Rc<dyn acp_thread::AgentTelemetry>)
2713    }
2714
2715    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
2716        self
2717    }
2718}
2719
2720impl acp_thread::AgentSessionClientUserMessageIds for NativeAgentConnection {
2721    fn prompt(
2722        &self,
2723        client_user_message_id: acp_thread::ClientUserMessageId,
2724        params: acp::PromptRequest,
2725        cx: &mut App,
2726    ) -> Task<Result<acp::PromptResponse>> {
2727        let session_id = params.session_id.clone();
2728        log::info!("Received prompt request for session: {}", session_id);
2729        log::debug!("Prompt blocks count: {}", params.prompt.len());
2730
2731        let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else {
2732            log::error!("Session not found in prompt: {}", session_id);
2733            if self.0.read(cx).sessions.contains_key(&session_id) {
2734                log::error!(
2735                    "Session found in sessions map, but not in project state: {}",
2736                    session_id
2737                );
2738            }
2739            return Task::ready(Err(anyhow::anyhow!("Session not found")));
2740        };
2741
2742        if let Some(parsed_command) = Command::parse(&params.prompt) {
2743            if parsed_command.is_unqualified(COMPACT_COMMAND_NAME) {
2744                return self.0.update(cx, |agent, cx| {
2745                    agent.send_compact_command(client_user_message_id, session_id, cx)
2746                });
2747            }
2748
2749            // Skill scope qualifiers (`/:<name>` and
2750            // `/<worktree>:<name>`) use a colon separator that can't
2751            // collide with MCP's `/<server>.<name>` grammar. The popup
2752            // inserts a qualified form for every skill so picking the
2753            // global row unambiguously runs the global skill even when
2754            // a same-named project-local one exists.
2755            if let Some(scope) = parsed_command.skill_scope
2756                && let Some(skill) = project_state.skills.iter().find(|skill| {
2757                    skill.name == parsed_command.prompt_name && skill.source.matches_scope(scope)
2758                })
2759            {
2760                let skill = skill.clone();
2761                return self.0.update(cx, |agent, cx| {
2762                    agent.send_skill_invocation(
2763                        client_user_message_id,
2764                        session_id.clone(),
2765                        skill,
2766                        params.prompt,
2767                        cx,
2768                    )
2769                });
2770            }
2771
2772            // MCP prompts and skills both register slash commands. MCP
2773            // prompts are checked first — if a user has both an MCP prompt
2774            // and a skill with the same name, the MCP prompt wins (matching
2775            // the order they appear in the catalog).
2776            let registry = project_state.context_server_registry.read(cx);
2777
2778            let explicit_server_id = parsed_command
2779                .explicit_server_id
2780                .map(|server_id| ContextServerId(server_id.into()));
2781
2782            if let Some(prompt) =
2783                registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
2784            {
2785                let arguments = if !parsed_command.arg_value.is_empty()
2786                    && let Some(arg_name) = prompt
2787                        .prompt
2788                        .arguments
2789                        .as_ref()
2790                        .and_then(|args| args.first())
2791                        .map(|arg| arg.name.clone())
2792                {
2793                    HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
2794                } else {
2795                    Default::default()
2796                };
2797
2798                let prompt_name = prompt.prompt.name.clone();
2799                let server_id = prompt.server_id.clone();
2800
2801                return self.0.update(cx, |agent, cx| {
2802                    agent.send_mcp_prompt(
2803                        client_user_message_id,
2804                        session_id.clone(),
2805                        prompt_name,
2806                        server_id,
2807                        arguments,
2808                        params.prompt,
2809                        cx,
2810                    )
2811                });
2812            }
2813
2814            // Unqualified skill match (`/skill-name` with no scope
2815            // prefix and no MCP server prefix). Slash commands work
2816            // for *all* skills regardless of `disable_model_invocation`
2817            // — that flag only hides the skill from the model's catalog.
2818            // The user explicitly typed the name, so they get to invoke
2819            // it.
2820            //
2821            // Inlined rather than calling `apply_skill_overrides` so
2822            // we don't clone the entire skill list on every prompt
2823            // (including prompts like `/help` that aren't skills at
2824            // all). The resolution rule matches the override-applied
2825            // view: among skills with the matching name, pick the one
2826            // with the highest source precedence, so the slash command
2827            // picks the same entry the model sees in its catalog.
2828            // Ties (e.g. two project-local skills from different
2829            // worktrees) resolve to the first in iteration order to
2830            // match `apply_skill_overrides`.
2831            if parsed_command.explicit_server_id.is_none()
2832                && parsed_command.skill_scope.is_none()
2833                && !project_state.skills.is_empty()
2834            {
2835                let prompt_name = parsed_command.prompt_name;
2836                let resolved = project_state
2837                    .skills
2838                    .iter()
2839                    .filter(|skill| skill.name == prompt_name)
2840                    .reduce(|best, candidate| {
2841                        if candidate.source.precedence() > best.source.precedence() {
2842                            candidate
2843                        } else {
2844                            best
2845                        }
2846                    });
2847                if let Some(skill) = resolved {
2848                    let skill = skill.clone();
2849                    return self.0.update(cx, |agent, cx| {
2850                        agent.send_skill_invocation(
2851                            client_user_message_id,
2852                            session_id.clone(),
2853                            skill,
2854                            params.prompt,
2855                            cx,
2856                        )
2857                    });
2858                }
2859            }
2860        };
2861
2862        let path_style = project_state.project.read(cx).path_style(cx);
2863
2864        self.run_turn(session_id, cx, move |thread, cx| {
2865            let content: Vec<UserMessageContent> = params
2866                .prompt
2867                .into_iter()
2868                .map(|block| UserMessageContent::from_content_block(block, path_style))
2869                .collect::<Vec<_>>();
2870            log::debug!("Converted prompt to message: {} chars", content.len());
2871            log::debug!("Client user message id: {:?}", client_user_message_id);
2872            log::debug!("Message content: {:?}", content);
2873
2874            thread.update(cx, |thread, cx| {
2875                thread.send(client_user_message_id, content, cx)
2876            })
2877        })
2878    }
2879}
2880
2881impl acp_thread::AgentTelemetry for NativeAgentConnection {
2882    fn thread_data(
2883        &self,
2884        session_id: &acp::SessionId,
2885        cx: &mut App,
2886    ) -> Task<Result<serde_json::Value>> {
2887        let Some(session) = self.0.read(cx).sessions.get(session_id) else {
2888            return Task::ready(Err(anyhow!("Session not found")));
2889        };
2890
2891        let task = session.thread.read(cx).to_db(cx);
2892        cx.background_spawn(async move {
2893            serde_json::to_value(task.await).context("Failed to serialize thread")
2894        })
2895    }
2896}
2897
2898pub struct NativeAgentSessionList {
2899    thread_store: Entity<ThreadStore>,
2900    updates_tx: async_channel::Sender<acp_thread::SessionListUpdate>,
2901    updates_rx: async_channel::Receiver<acp_thread::SessionListUpdate>,
2902    _subscription: Subscription,
2903}
2904
2905impl NativeAgentSessionList {
2906    fn new(thread_store: Entity<ThreadStore>, cx: &mut App) -> Self {
2907        let (tx, rx) = async_channel::unbounded();
2908        let this_tx = tx.clone();
2909        let subscription = cx.observe(&thread_store, move |_, _| {
2910            this_tx
2911                .try_send(acp_thread::SessionListUpdate::Refresh)
2912                .ok();
2913        });
2914        Self {
2915            thread_store,
2916            updates_tx: tx,
2917            updates_rx: rx,
2918            _subscription: subscription,
2919        }
2920    }
2921
2922    pub fn thread_store(&self) -> &Entity<ThreadStore> {
2923        &self.thread_store
2924    }
2925}
2926
2927impl AgentSessionList for NativeAgentSessionList {
2928    fn list_sessions(
2929        &self,
2930        _request: AgentSessionListRequest,
2931        cx: &mut App,
2932    ) -> Task<Result<AgentSessionListResponse>> {
2933        let sessions = self
2934            .thread_store
2935            .read(cx)
2936            .entries()
2937            .map(|entry| AgentSessionInfo::from(&entry))
2938            .collect();
2939        Task::ready(Ok(AgentSessionListResponse::new(sessions)))
2940    }
2941
2942    fn supports_delete(&self) -> bool {
2943        true
2944    }
2945
2946    fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task<Result<()>> {
2947        self.thread_store
2948            .update(cx, |store, cx| store.delete_thread(session_id.clone(), cx))
2949    }
2950
2951    fn delete_sessions(&self, cx: &mut App) -> Task<Result<()>> {
2952        self.thread_store
2953            .update(cx, |store, cx| store.delete_threads(cx))
2954    }
2955
2956    fn watch(
2957        &self,
2958        _cx: &mut App,
2959    ) -> Option<async_channel::Receiver<acp_thread::SessionListUpdate>> {
2960        Some(self.updates_rx.clone())
2961    }
2962
2963    fn notify_refresh(&self) {
2964        self.updates_tx
2965            .try_send(acp_thread::SessionListUpdate::Refresh)
2966            .ok();
2967    }
2968
2969    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
2970        self
2971    }
2972}
2973
2974struct NativeAgentSessionTruncate {
2975    thread: Entity<Thread>,
2976    acp_thread: WeakEntity<AcpThread>,
2977}
2978
2979impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
2980    fn run(
2981        &self,
2982        client_user_message_id: acp_thread::ClientUserMessageId,
2983        cx: &mut App,
2984    ) -> Task<Result<()>> {
2985        match self.thread.update(cx, |thread, cx| {
2986            thread.truncate(client_user_message_id.clone(), cx)?;
2987            Ok(thread.latest_token_usage())
2988        }) {
2989            Ok(usage) => {
2990                self.acp_thread
2991                    .update(cx, |thread, cx| {
2992                        thread.update_token_usage(usage, cx);
2993                    })
2994                    .ok();
2995                Task::ready(Ok(()))
2996            }
2997            Err(error) => Task::ready(Err(error)),
2998        }
2999    }
3000}
3001
3002struct NativeAgentSessionRetry {
3003    connection: NativeAgentConnection,
3004    session_id: acp::SessionId,
3005}
3006
3007impl acp_thread::AgentSessionRetry for NativeAgentSessionRetry {
3008    fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>> {
3009        self.connection
3010            .run_turn(self.session_id.clone(), cx, |thread, cx| {
3011                thread.update(cx, |thread, cx| thread.resume(cx))
3012            })
3013    }
3014}
3015
3016struct NativeAgentSessionSetTitle {
3017    thread: Entity<Thread>,
3018}
3019
3020impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
3021    fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>> {
3022        self.thread
3023            .update(cx, |thread, cx| thread.set_title(title, cx));
3024        Task::ready(Ok(()))
3025    }
3026}
3027
3028pub struct NativeThreadEnvironment {
3029    agent: WeakEntity<NativeAgent>,
3030    thread: WeakEntity<Thread>,
3031    acp_thread: WeakEntity<AcpThread>,
3032}
3033
3034impl NativeThreadEnvironment {
3035    pub(crate) fn create_subagent_thread(
3036        &self,
3037        label: String,
3038        cx: &mut App,
3039    ) -> Result<Rc<dyn SubagentHandle>> {
3040        let Some(parent_thread_entity) = self.thread.upgrade() else {
3041            anyhow::bail!("Parent thread no longer exists".to_string());
3042        };
3043        let parent_thread = parent_thread_entity.read(cx);
3044        let current_depth = parent_thread.depth();
3045        let parent_session_id = parent_thread.id().clone();
3046
3047        if current_depth >= MAX_SUBAGENT_DEPTH {
3048            return Err(anyhow!(
3049                "Maximum subagent depth ({}) reached",
3050                MAX_SUBAGENT_DEPTH
3051            ));
3052        }
3053
3054        let subagent_thread: Entity<Thread> = cx.new(|cx| {
3055            let mut thread = Thread::new_subagent(&parent_thread_entity, cx);
3056            thread.set_title(label.into(), cx);
3057            thread
3058        });
3059
3060        let session_id = subagent_thread.read(cx).id().clone();
3061
3062        let acp_thread = self
3063            .agent
3064            .update(cx, |agent, cx| -> Result<Entity<AcpThread>> {
3065                let project_id = agent
3066                    .sessions
3067                    .get(&parent_session_id)
3068                    .map(|s| s.project_id)
3069                    .context("parent session not found")?;
3070                Ok(agent.register_session(subagent_thread.clone(), project_id, 1, cx))
3071            })??;
3072
3073        let depth = current_depth + 1;
3074
3075        telemetry::event!(
3076            "Subagent Started",
3077            session = parent_thread_entity.read(cx).id().to_string(),
3078            subagent_session = session_id.to_string(),
3079            depth,
3080            is_resumed = false,
3081        );
3082
3083        self.prompt_subagent(session_id, subagent_thread, acp_thread)
3084    }
3085
3086    pub(crate) fn resume_subagent_thread(
3087        &self,
3088        session_id: acp::SessionId,
3089        cx: &mut App,
3090    ) -> Result<Rc<dyn SubagentHandle>> {
3091        let (subagent_thread, acp_thread) = self.agent.update(cx, |agent, _cx| {
3092            let session = agent
3093                .sessions
3094                .get(&session_id)
3095                .ok_or_else(|| anyhow!("No subagent session found with id {session_id}"))?;
3096            anyhow::Ok((session.thread.clone(), session.acp_thread.clone()))
3097        })??;
3098
3099        let depth = subagent_thread.read(cx).depth();
3100
3101        if let Some(parent_thread_entity) = self.thread.upgrade() {
3102            telemetry::event!(
3103                "Subagent Started",
3104                session = parent_thread_entity.read(cx).id().to_string(),
3105                subagent_session = session_id.to_string(),
3106                depth,
3107                is_resumed = true,
3108            );
3109        }
3110
3111        self.prompt_subagent(session_id, subagent_thread, acp_thread)
3112    }
3113
3114    fn prompt_subagent(
3115        &self,
3116        session_id: acp::SessionId,
3117        subagent_thread: Entity<Thread>,
3118        acp_thread: Entity<acp_thread::AcpThread>,
3119    ) -> Result<Rc<dyn SubagentHandle>> {
3120        let Some(parent_thread_entity) = self.thread.upgrade() else {
3121            anyhow::bail!("Parent thread no longer exists".to_string());
3122        };
3123        Ok(Rc::new(NativeSubagentHandle::new(
3124            session_id,
3125            subagent_thread,
3126            acp_thread,
3127            parent_thread_entity,
3128        )) as _)
3129    }
3130}
3131
3132impl ThreadEnvironment for NativeThreadEnvironment {
3133    fn create_terminal(
3134        &self,
3135        command: String,
3136        extra_env: Vec<acp::EnvVariable>,
3137        cwd: Option<PathBuf>,
3138        output_byte_limit: Option<u64>,
3139        sandbox_wrap: Option<acp_thread::SandboxWrap>,
3140        cx: &mut AsyncApp,
3141    ) -> Task<Result<Rc<dyn TerminalHandle>>> {
3142        // On Seatbelt-style sandboxes (macOS) there's no tmpfs overlay, so to
3143        // give the command a writable temp area we point `$TMPDIR`/`$TMP`/
3144        // `$TEMP` at a per-thread directory inside the sandbox's writable
3145        // scope. Doing this even when sandboxing is disabled keeps `$TMPDIR`
3146        // stable so the model can't infer sandbox state from it.
3147        //
3148        // Only do this for local projects. For remote projects the temp
3149        // directory would be created on the client, but the terminal runs on
3150        // the remote host, so pointing `$TMPDIR` (and the sandbox writable
3151        // scope) at a client-side path would leak client environment into the
3152        // remote terminal and reference a directory that doesn't exist there.
3153        //
3154        // Linux and Windows are excluded: the bwrap sandbox (run directly on
3155        // Linux, and via WSL on Windows) already mounts a fresh, writable
3156        // `tmpfs` over `/tmp`, so the environment looks like a normal
3157        // filesystem with no special `$TMPDIR` (which would only make the
3158        // sandbox more obviously Zed-specific). On Windows a per-thread
3159        // `$TMPDIR` would also be a Windows path that's meaningless inside
3160        // WSL, and adding it to the writable scope would bind a stray
3161        // `/mnt/<drive>/...` path.
3162        #[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))]
3163        let mut extra_env = extra_env;
3164        #[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))]
3165        let mut sandbox_wrap = sandbox_wrap;
3166        #[cfg(not(any(target_os = "linux", target_os = "windows")))]
3167        {
3168            let temp_dir = self.thread.update(cx, |thread, cx| {
3169                thread
3170                    .project()
3171                    .read(cx)
3172                    .is_local()
3173                    .then(|| thread.sandboxed_terminal_temp_dir(cx))
3174            });
3175            match temp_dir {
3176                Ok(Some(Ok(temp_dir))) => {
3177                    // Canonicalize so the path matches what the sandbox
3178                    // resolves symlinks to (e.g. `/var` -> `/private/var` on
3179                    // macOS). `$TMPDIR` and the writable-scope entry below must
3180                    // agree, and they must agree with the path the kernel
3181                    // actually checks.
3182                    let temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir);
3183                    let temp_dir_string = temp_dir.to_string_lossy().into_owned();
3184                    extra_env.extend([
3185                        acp::EnvVariable::new("TMPDIR", &temp_dir_string),
3186                        acp::EnvVariable::new("TMP", &temp_dir_string),
3187                        acp::EnvVariable::new("TEMP", &temp_dir_string),
3188                    ]);
3189                    // The command's `$TMPDIR` must live inside the sandbox's
3190                    // writable scope. The per-thread temp directory is owned
3191                    // here (not in the terminal tool that assembles the rest
3192                    // of the writable set), so add it whenever the command is
3193                    // sandboxed.
3194                    if let Some(sandbox_wrap) = &mut sandbox_wrap {
3195                        sandbox_wrap.writable_paths.push(temp_dir);
3196                    }
3197                }
3198                Ok(None) => {}
3199                Ok(Some(Err(error))) => return Task::ready(Err(error)),
3200                Err(error) => return Task::ready(Err(error)),
3201            };
3202        }
3203        let task = self.acp_thread.update(cx, |thread, cx| {
3204            thread.create_terminal(
3205                command,
3206                vec![],
3207                extra_env,
3208                cwd,
3209                output_byte_limit,
3210                sandbox_wrap,
3211                cx,
3212            )
3213        });
3214
3215        let acp_thread = self.acp_thread.clone();
3216        cx.spawn(async move |cx| {
3217            let terminal = task?.await?;
3218
3219            let (drop_tx, drop_rx) = oneshot::channel();
3220            let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone());
3221
3222            cx.spawn(async move |cx| {
3223                drop_rx.await.ok();
3224                acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
3225            })
3226            .detach();
3227
3228            let handle = AcpTerminalHandle {
3229                terminal,
3230                _drop_tx: Some(drop_tx),
3231            };
3232
3233            Ok(Rc::new(handle) as _)
3234        })
3235    }
3236
3237    fn create_subagent(&self, label: String, cx: &mut App) -> Result<Rc<dyn SubagentHandle>> {
3238        self.create_subagent_thread(label, cx)
3239    }
3240
3241    fn resume_subagent(
3242        &self,
3243        session_id: acp::SessionId,
3244        cx: &mut App,
3245    ) -> Result<Rc<dyn SubagentHandle>> {
3246        self.resume_subagent_thread(session_id, cx)
3247    }
3248
3249    fn create_sibling_thread(
3250        &self,
3251        request: SiblingThreadRequest,
3252        cx: &mut AsyncApp,
3253    ) -> Task<Result<SiblingThreadInfo>> {
3254        let host = match self
3255            .agent
3256            .read_with(cx, |agent, _| agent.sibling_thread_host())
3257        {
3258            Ok(Some(host)) => host,
3259            Ok(None) => {
3260                return Task::ready(Err(anyhow!(
3261                    "No sibling-thread host is registered. This usually means the \
3262                     agent panel hasn't been initialized in this workspace."
3263                )));
3264            }
3265            Err(err) => return Task::ready(Err(err)),
3266        };
3267        host.create_sibling_thread(request, cx)
3268    }
3269
3270    fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents> {
3271        let host = self
3272            .agent
3273            .read_with(cx, |agent, _| agent.sibling_thread_host())?
3274            .ok_or_else(|| {
3275                anyhow!(
3276                    "No sibling-thread host is registered. This usually means the \
3277                     agent panel hasn't been initialized in this workspace."
3278                )
3279            })?;
3280        host.list_available_agents(cx)
3281    }
3282}
3283
3284#[derive(Debug, Clone)]
3285enum SubagentPromptResult {
3286    Completed,
3287    Cancelled,
3288    ContextWindowWarning,
3289    Error(String),
3290}
3291
3292pub struct NativeSubagentHandle {
3293    session_id: acp::SessionId,
3294    parent_thread: WeakEntity<Thread>,
3295    subagent_thread: Entity<Thread>,
3296    acp_thread: Entity<acp_thread::AcpThread>,
3297}
3298
3299impl NativeSubagentHandle {
3300    fn new(
3301        session_id: acp::SessionId,
3302        subagent_thread: Entity<Thread>,
3303        acp_thread: Entity<acp_thread::AcpThread>,
3304        parent_thread_entity: Entity<Thread>,
3305    ) -> Self {
3306        NativeSubagentHandle {
3307            session_id,
3308            subagent_thread,
3309            parent_thread: parent_thread_entity.downgrade(),
3310            acp_thread,
3311        }
3312    }
3313}
3314
3315impl SubagentHandle for NativeSubagentHandle {
3316    fn id(&self) -> acp::SessionId {
3317        self.session_id.clone()
3318    }
3319
3320    fn num_entries(&self, cx: &App) -> usize {
3321        self.acp_thread.read(cx).entries().len()
3322    }
3323
3324    fn send(&self, message: String, cx: &AsyncApp) -> Task<Result<String>> {
3325        let thread = self.subagent_thread.clone();
3326        let acp_thread = self.acp_thread.clone();
3327        let subagent_session_id = self.session_id.clone();
3328        let parent_thread = self.parent_thread.clone();
3329
3330        cx.spawn(async move |cx| {
3331            let (task, _subscription) = cx.update(|cx| {
3332                let ratio_before_prompt = thread
3333                    .read(cx)
3334                    .latest_token_usage()
3335                    .map(|usage| usage.ratio());
3336
3337                parent_thread
3338                    .update(cx, |parent_thread, _cx| {
3339                        parent_thread.register_running_subagent(thread.downgrade())
3340                    })
3341                    .ok();
3342
3343                let task = acp_thread.update(cx, |acp_thread, cx| {
3344                    acp_thread.send(vec![message.into()], cx)
3345                });
3346
3347                let (token_limit_tx, token_limit_rx) = oneshot::channel::<()>();
3348                let mut token_limit_tx = Some(token_limit_tx);
3349
3350                let subscription = cx.subscribe(
3351                    &thread,
3352                    move |_thread, event: &TokenUsageUpdated, _cx| {
3353                        if let Some(usage) = &event.0 {
3354                            let old_ratio = ratio_before_prompt
3355                                .clone()
3356                                .unwrap_or(TokenUsageRatio::Normal);
3357                            let new_ratio = usage.ratio();
3358                            if old_ratio == TokenUsageRatio::Normal
3359                                && new_ratio == TokenUsageRatio::Warning
3360                            {
3361                                if let Some(tx) = token_limit_tx.take() {
3362                                    tx.send(()).ok();
3363                                }
3364                            }
3365                        }
3366                    },
3367                );
3368
3369                let wait_for_prompt = cx
3370                    .background_spawn(async move {
3371                        futures::select! {
3372                            response = task.fuse() => match response {
3373                                Ok(Some(response)) => {
3374                                    match response.stop_reason {
3375                                        acp::StopReason::Cancelled => SubagentPromptResult::Cancelled,
3376                                        acp::StopReason::MaxTokens => SubagentPromptResult::Error("The agent reached the maximum number of tokens.".into()),
3377                                        acp::StopReason::MaxTurnRequests => SubagentPromptResult::Error("The agent reached the maximum number of allowed requests between user turns. Try prompting again.".into()),
3378                                        acp::StopReason::Refusal => SubagentPromptResult::Error("The agent refused to process that prompt. Try again.".into()),
3379                                        acp::StopReason::EndTurn | _ => SubagentPromptResult::Completed,
3380                                    }
3381                                }
3382                                Ok(None) => SubagentPromptResult::Error("No response from the agent. You can try messaging again.".into()),
3383                                Err(error) => SubagentPromptResult::Error(error.to_string()),
3384                            },
3385                            _ = token_limit_rx.fuse() => SubagentPromptResult::ContextWindowWarning,
3386                        }
3387                    });
3388
3389                (wait_for_prompt, subscription)
3390            });
3391
3392            let result = match task.await {
3393                SubagentPromptResult::Completed => thread.read_with(cx, |thread, _cx| {
3394                    thread
3395                        .last_message()
3396                        .and_then(|message| {
3397                            let content = message.as_agent_message()?
3398                                .content
3399                                .iter()
3400                                .filter_map(|c| match c {
3401                                    AgentMessageContent::Text(text) => Some(text.as_str()),
3402                                    _ => None,
3403                                })
3404                                .join("\n\n");
3405                            if content.is_empty() {
3406                                None
3407                            } else {
3408                                Some( content)
3409                            }
3410                        })
3411                        .context("No response from subagent")
3412                }),
3413                SubagentPromptResult::Cancelled => Err(anyhow!("User canceled")),
3414                SubagentPromptResult::Error(message) => Err(anyhow!("{message}")),
3415                SubagentPromptResult::ContextWindowWarning => {
3416                    thread.update(cx, |thread, cx| thread.cancel(cx)).await;
3417                    Err(anyhow!(
3418                        "The agent is nearing the end of its context window and has been \
3419                         stopped. You can prompt the thread again to have the agent wrap up \
3420                         or hand off its work."
3421                    ))
3422                }
3423            };
3424
3425            parent_thread
3426                .update(cx, |parent_thread, cx| {
3427                    parent_thread.unregister_running_subagent(&subagent_session_id, cx)
3428                })
3429                .ok();
3430
3431            result
3432        })
3433    }
3434}
3435
3436pub struct AcpTerminalHandle {
3437    terminal: Entity<acp_thread::Terminal>,
3438    _drop_tx: Option<oneshot::Sender<()>>,
3439}
3440
3441impl TerminalHandle for AcpTerminalHandle {
3442    fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId> {
3443        Ok(self.terminal.read_with(cx, |term, _cx| term.id().clone()))
3444    }
3445
3446    fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
3447        Ok(self
3448            .terminal
3449            .read_with(cx, |term, _cx| term.wait_for_exit()))
3450    }
3451
3452    fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
3453        Ok(self
3454            .terminal
3455            .read_with(cx, |term, cx| term.current_output(cx)))
3456    }
3457
3458    fn kill(&self, cx: &AsyncApp) -> Result<()> {
3459        cx.update(|cx| {
3460            self.terminal.update(cx, |terminal, cx| {
3461                terminal.kill(cx);
3462            });
3463        });
3464        Ok(())
3465    }
3466
3467    fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result<bool> {
3468        Ok(self
3469            .terminal
3470            .read_with(cx, |term, _cx| term.was_stopped_by_user()))
3471    }
3472}
3473
3474/// Build the catalog the model sees in its system prompt: filter out hidden
3475/// (`disable_model_invocation`) skills, then drop the rest if they would push
3476/// the catalog past the description budget.
3477///
3478/// Returns `SkillSummary` values rather than full `Skill`s so that the
3479/// (potentially ~100KB) skill bodies aren't cloned just to be discarded by
3480/// `ProjectContext::new`, which only needs the summary fields.
3481fn select_catalog_skills(skills: &[Skill]) -> (Vec<SkillSummary>, Vec<SkillLoadingIssueData>) {
3482    let mut kept = Vec::new();
3483    let mut issues = Vec::new();
3484    let mut dropped: Vec<&Skill> = Vec::new();
3485    let mut total_size = 0usize;
3486    let mut budget_exceeded = false;
3487
3488    for skill in skills {
3489        if skill.disable_model_invocation {
3490            continue;
3491        }
3492
3493        let entry_size = skill.name.len() + skill.description.len();
3494        if !budget_exceeded && total_size.saturating_add(entry_size) <= MAX_SKILL_DESCRIPTIONS_SIZE
3495        {
3496            total_size += entry_size;
3497            kept.push(SkillSummary::from(skill));
3498        } else {
3499            // Once any model-invocable skill overflows the budget, stop
3500            // packing entirely so the cutoff is deterministic by sort order
3501            // rather than dependent on which skills happen to be small
3502            // enough to fit in the remaining space.
3503            budget_exceeded = true;
3504            dropped.push(skill);
3505        }
3506    }
3507
3508    if !dropped.is_empty() {
3509        let budget_kb = MAX_SKILL_DESCRIPTIONS_SIZE / 1024;
3510        let first = dropped[0];
3511        let message = if dropped.len() == 1 {
3512            let entry_size = first.name.len() + first.description.len();
3513            format!(
3514                "Skill '{}' ({:.1}KB description) was dropped from the catalog because the previous skills already used the entire {}KB description budget.",
3515                first.name,
3516                entry_size as f64 / 1024.0,
3517                budget_kb,
3518            )
3519        } else {
3520            let mut message = format!(
3521                "{} skills were dropped from the catalog because they exceeded the {}KB description budget:",
3522                dropped.len(),
3523                budget_kb,
3524            );
3525            for skill in &dropped {
3526                let entry_size = skill.name.len() + skill.description.len();
3527                message.push('\n');
3528                message.push_str(&format!(
3529                    "- {} ({:.1}KB description)",
3530                    skill.name,
3531                    entry_size as f64 / 1024.0,
3532                ));
3533            }
3534            message
3535        };
3536        issues.push(SkillLoadingIssueData::catalog_budget_exceeded(
3537            first.skill_file_path.clone(),
3538            message,
3539        ));
3540    }
3541
3542    (kept, issues)
3543}
3544
3545/// Build a closure that, when called, reads the latest `state.skills`
3546/// for the given project from the `NativeAgent` and applies
3547/// project-overrides-global so the `SkillTool` resolves a name to the
3548/// same entry the model sees in its catalog. Run at invocation time
3549/// (not thread-build time) so skill changes after thread construction
3550/// become visible without re-registering the tool.
3551pub fn skills_resolver_for_project(
3552    weak_agent: WeakEntity<NativeAgent>,
3553    project_id: EntityId,
3554) -> impl Fn(&App) -> Arc<Vec<Skill>> + Send + Sync + 'static {
3555    move |cx: &App| {
3556        weak_agent
3557            .upgrade()
3558            .and_then(|agent| {
3559                agent
3560                    .read(cx)
3561                    .projects
3562                    .get(&project_id)
3563                    .map(|state| Arc::new(apply_skill_overrides(&state.skills)))
3564            })
3565            .unwrap_or_else(|| Arc::new(Vec::new()))
3566    }
3567}
3568
3569pub fn skill_body_resolver_for_project(
3570    project: Entity<Project>,
3571    fs: Arc<dyn Fs>,
3572) -> impl Fn(Skill, &mut AsyncApp) -> Task<Result<String>> + Send + Sync + 'static {
3573    move |skill, cx| match skill.source.clone() {
3574        SkillSource::ProjectLocal { worktree_id, .. } => {
3575            let project = project.clone();
3576            cx.spawn(async move |cx| {
3577                let worktree_id = WorktreeId::from_usize(worktree_id.0);
3578                let worktree = project
3579                    .update(cx, |project, cx| project.worktree_for_id(worktree_id, cx))
3580                    .context("no such worktree")?;
3581                expand_project_skills_directories(&worktree, cx).await?;
3582                let relative_path = worktree.update(cx, |worktree, _cx| {
3583                    let worktree_root = worktree.abs_path();
3584                    worktree
3585                        .path_style()
3586                        .strip_prefix(&skill.skill_file_path, &worktree_root)
3587                        .map(|relative_path| relative_path.into_arc())
3588                        .context("skill file is not inside its worktree")
3589                })?;
3590
3591                let buffer = project
3592                    .update(cx, |project, cx| {
3593                        project.open_buffer((worktree_id, relative_path), cx)
3594                    })
3595                    .await?;
3596                let content =
3597                    cx.update(|cx| buffer.read(cx).as_text_snapshot().as_rope().to_string());
3598
3599                read_skill_body_from_content(&skill.skill_file_path, &content).map_err(Into::into)
3600            })
3601        }
3602        SkillSource::BuiltIn | SkillSource::Global => {
3603            let fs = fs.clone();
3604            cx.background_spawn(async move {
3605                agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
3606                    .await
3607                    .map_err(Into::into)
3608            })
3609        }
3610    }
3611}
3612
3613/// Collect successfully-loaded global and project-local skills into a
3614/// single list, preserving every entry — even when two skills share a
3615/// name. The autocomplete popup shows the full list with origin labels
3616/// so users can tell same-named skills apart; override resolution
3617/// (project-local wins over global) happens later via
3618/// [`apply_skill_overrides`] at the boundaries where the model
3619/// interacts with skills (system-prompt catalog, `SkillTool` lookup,
3620/// slash-command invocation).
3621///
3622/// Global versions of skills will be before the local versions
3623fn combine_skills(
3624    global: Vec<Result<Skill, SkillLoadError>>,
3625    project: impl Iterator<Item = Result<Skill, SkillLoadError>>,
3626) -> (Vec<Skill>, Vec<SkillLoadError>) {
3627    // Built-in skills go first (lowest priority) so that global and
3628    // project-local skills with the same name shadow them.
3629    let mut skills = builtin_skills();
3630    let mut errors = Vec::new();
3631    for result in global.into_iter().chain(project) {
3632        match result {
3633            Ok(skill) => skills.push(skill),
3634            Err(e) => errors.push(e),
3635        }
3636    }
3637    log_skill_conflicts(&skills);
3638    (skills, errors)
3639}
3640
3641/// Emit a warning for each name collision between skills. Called once
3642/// per skill load (not per query), so the log isn't spammed by repeated
3643/// catalog rebuilds.
3644fn log_skill_conflicts(skills: &[Skill]) {
3645    let mut by_name: HashMap<&str, &Skill> = HashMap::default();
3646    for skill in skills {
3647        match by_name.get(skill.name.as_str()) {
3648            Some(existing) => {
3649                if skill.source.precedence() > existing.source.precedence() {
3650                    log::warn!(
3651                        "Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source",
3652                        skill.name,
3653                        skill.skill_file_path.display(),
3654                        existing.skill_file_path.display(),
3655                    );
3656                    by_name.insert(skill.name.as_str(), skill);
3657                } else {
3658                    log::warn!(
3659                        "Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source",
3660                        skill.name,
3661                        skill.skill_file_path.display(),
3662                        existing.skill_file_path.display(),
3663                    );
3664                }
3665            }
3666            None => {
3667                by_name.insert(skill.name.as_str(), skill);
3668            }
3669        }
3670    }
3671}
3672
3673/// Project-local skills override same-named global skills. Returns a
3674/// new list with at most one entry per name. Two skills of the same
3675/// source colliding (e.g. two globals or two project-locals) keep the
3676/// first one to match the historical behavior.
3677///
3678/// This is the projection of `state.skills` used by everything the
3679/// model interacts with: the system-prompt catalog, the `SkillTool`'s
3680/// name resolver, and slash-command invocation. The autocomplete popup
3681/// deliberately does *not* go through this — it shows the full list so
3682/// users can see what's shadowed.
3683fn apply_skill_overrides(skills: &[Skill]) -> Vec<Skill> {
3684    let mut result: Vec<Skill> = Vec::new();
3685    // Borrow names from the input slice so the dedup index doesn't
3686    // need to allocate a `String` per skill. The borrow is valid for
3687    // the body of the function because `skills` outlives `indices`.
3688    let mut indices: HashMap<&str, usize> = HashMap::default();
3689    for skill in skills {
3690        match indices.get(skill.name.as_str()).copied() {
3691            Some(idx) => {
3692                if skill.source.precedence() > result[idx].source.precedence() {
3693                    result[idx] = skill.clone();
3694                }
3695            }
3696            None => {
3697                indices.insert(skill.name.as_str(), result.len());
3698                result.push(skill.clone());
3699            }
3700        }
3701    }
3702    result
3703}
3704
3705#[cfg(test)]
3706mod internal_tests {
3707    use std::path::Path;
3708
3709    use super::*;
3710    use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri};
3711    use agent_settings::COMPACTION_PROMPT;
3712    use fs::FakeFs;
3713    use gpui::TestAppContext;
3714    use indoc::formatdoc;
3715    use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider};
3716    use language_model::{
3717        CompletionIntent, LanguageModelCompletionEvent, LanguageModelProviderId,
3718        LanguageModelProviderName,
3719    };
3720    use serde_json::json;
3721    use settings::SettingsStore;
3722    use util::{path, rel_path::rel_path};
3723
3724    fn make_global_skill(name: &str, description: &str) -> Skill {
3725        Skill {
3726            name: name.to_string(),
3727            description: description.to_string(),
3728            source: SkillSource::Global,
3729            directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")),
3730            skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")),
3731            load_warnings: Vec::new(),
3732            disable_model_invocation: false,
3733            embedded_body: None,
3734        }
3735    }
3736
3737    async fn setup_native_agent_session(
3738        cx: &mut TestAppContext,
3739    ) -> (
3740        Rc<NativeAgentConnection>,
3741        Entity<NativeAgent>,
3742        Entity<Project>,
3743        Entity<AcpThread>,
3744    ) {
3745        let fs = FakeFs::new(cx.executor());
3746        fs.insert_tree("/", json!({ "a": {} })).await;
3747        let project = Project::test(fs.clone(), [Path::new("/a")], cx).await;
3748        let thread_store = cx.new(|cx| ThreadStore::new(cx));
3749        let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx));
3750        let connection = Rc::new(NativeAgentConnection(agent.clone()));
3751        let acp_thread = cx
3752            .update(|cx| {
3753                connection.clone().new_session(
3754                    project.clone(),
3755                    PathList::new(&[Path::new("/a")]),
3756                    cx,
3757                )
3758            })
3759            .await
3760            .unwrap();
3761
3762        (connection, agent, project, acp_thread)
3763    }
3764
3765    fn native_thread_for_session(
3766        agent: &Entity<NativeAgent>,
3767        session_id: &acp::SessionId,
3768        cx: &App,
3769    ) -> Entity<Thread> {
3770        agent.read_with(cx, |agent, _cx| {
3771            agent.sessions.get(session_id).unwrap().thread.clone()
3772        })
3773    }
3774
3775    fn request_texts_after_system(
3776        messages: &[language_model::LanguageModelRequestMessage],
3777    ) -> Vec<String> {
3778        messages
3779            .iter()
3780            .skip(1)
3781            .map(language_model::LanguageModelRequestMessage::string_contents)
3782            .collect()
3783    }
3784
3785    #[gpui::test]
3786    async fn test_compact_command_is_available(cx: &mut TestAppContext) {
3787        init_test(cx);
3788        let fs = FakeFs::new(cx.executor());
3789        let project = Project::test(fs.clone(), [], cx).await;
3790        let thread_store = cx.new(|cx| ThreadStore::new(cx));
3791        let agent =
3792            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
3793
3794        let connection = NativeAgentConnection(agent.clone());
3795        let acp_thread = cx
3796            .update(|cx| {
3797                Rc::new(connection.clone()).new_session(
3798                    project.clone(),
3799                    PathList::new(&[Path::new("/")]),
3800                    cx,
3801                )
3802            })
3803            .await
3804            .unwrap();
3805        cx.run_until_parked();
3806
3807        cx.update(|cx| {
3808            let commands = acp_thread.read(cx).available_commands();
3809
3810            let compact = commands.iter().find(|command| command.name == "compact");
3811            let compact = compact.expect("compact command should be available");
3812            assert_eq!(
3813                acp_thread::command_category_from_meta(&compact.meta),
3814                Some(acp_thread::CommandCategory::Native),
3815            );
3816        });
3817    }
3818
3819    #[gpui::test]
3820    async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) {
3821        init_test(cx);
3822        let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
3823        let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
3824        let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
3825        let model = Arc::new(FakeLanguageModel::default());
3826        let old_message_id = ClientUserMessageId::new();
3827
3828        cx.update(|cx| {
3829            let path_style = project.read(cx).path_style(cx);
3830            thread.update(cx, |thread, cx| {
3831                thread.set_model(model.clone(), cx);
3832                thread.push_acp_user_block(
3833                    old_message_id,
3834                    [acp::ContentBlock::from("old user")],
3835                    path_style,
3836                    cx,
3837                );
3838                thread.push_acp_agent_block("old assistant".into(), cx);
3839            });
3840        });
3841
3842        let compact_message_id = ClientUserMessageId::new();
3843        let prompt_task = cx.update(|cx| {
3844            acp_thread::AgentSessionClientUserMessageIds::prompt(
3845                connection.as_ref(),
3846                compact_message_id,
3847                acp::PromptRequest::new(session_id.clone(), vec!["/compact".into()]),
3848                cx,
3849            )
3850        });
3851        cx.run_until_parked();
3852
3853        let request = model.pending_completions().pop().unwrap();
3854        assert_eq!(
3855            request.intent,
3856            Some(CompletionIntent::ThreadContextSummarization)
3857        );
3858        assert_eq!(
3859            request_texts_after_system(&request.messages),
3860            vec![
3861                "old user".to_string(),
3862                "old assistant".to_string(),
3863                COMPACTION_PROMPT.to_string(),
3864            ]
3865        );
3866
3867        model.send_completion_stream_text_chunk(&request, "summary");
3868        model.end_completion_stream(&request);
3869        cx.run_until_parked();
3870        prompt_task.await.unwrap();
3871    }
3872
3873    #[gpui::test]
3874    async fn test_threads_flushed_to_database_on_app_quit(cx: &mut TestAppContext) {
3875        init_test(cx);
3876
3877        let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
3878        let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
3879        let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
3880
3881        // A second session whose thread stays empty must be skipped by the
3882        // quit flush rather than persisted as an empty row.
3883        let empty_acp_thread = cx
3884            .update(|cx| {
3885                connection.clone().new_session(
3886                    project.clone(),
3887                    PathList::new(&[Path::new("/a")]),
3888                    cx,
3889                )
3890            })
3891            .await
3892            .unwrap();
3893        let empty_session_id = cx.update(|cx| empty_acp_thread.read(cx).session_id().clone());
3894
3895        // Give the first thread content so it's no longer an empty draft, plus
3896        // an in-progress draft prompt that the flush must capture.
3897        cx.update(|cx| {
3898            let path_style = project.read(cx).path_style(cx);
3899            thread.update(cx, |thread, cx| {
3900                thread.push_acp_user_block(
3901                    ClientUserMessageId::new(),
3902                    [acp::ContentBlock::from("hello from the user")],
3903                    path_style,
3904                    cx,
3905                );
3906            });
3907            acp_thread.update(cx, |acp_thread, cx| {
3908                acp_thread
3909                    .set_draft_prompt(Some(vec![acp::ContentBlock::from("draft in progress")]), cx);
3910            });
3911        });
3912        cx.run_until_parked();
3913
3914        // Reproduce the orphaned state from the bug: the sidebar metadata and
3915        // serialized panel still reference the session, but the per-session
3916        // async content save never landed, so the content row is absent.
3917        let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap();
3918        database.delete_thread(session_id.clone()).await.unwrap();
3919        assert!(
3920            database
3921                .load_thread(session_id.clone())
3922                .await
3923                .unwrap()
3924                .is_none(),
3925            "precondition: content row should be missing before the quit flush"
3926        );
3927
3928        // Quit through the real shutdown path so the `on_app_quit`
3929        // registration is exercised, not just the flush itself.
3930        cx.update(|cx| cx.shutdown());
3931
3932        let restored = database
3933            .load_thread(session_id.clone())
3934            .await
3935            .unwrap()
3936            .expect("thread content should be persisted to the database on quit");
3937        assert_eq!(
3938            restored.messages.len(),
3939            1,
3940            "the user message should survive the quit flush"
3941        );
3942        assert_eq!(
3943            restored.draft_prompt,
3944            Some(vec![acp::ContentBlock::from("draft in progress")]),
3945            "the current draft prompt should be captured by the quit flush"
3946        );
3947        assert!(
3948            database
3949                .load_thread(empty_session_id)
3950                .await
3951                .unwrap()
3952                .is_none(),
3953            "empty threads should not be persisted by the quit flush"
3954        );
3955    }
3956
3957    #[test]
3958    fn test_ambiguous_mcp_prompt_names() {
3959        // Reserving the built-in `/compact` forces a same-named MCP prompt to be
3960        // server-qualified so it stays reachable; unique names stay bare.
3961        let ambiguous = ambiguous_mcp_prompt_names([COMPACT_COMMAND_NAME], ["compact", "deploy"]);
3962        assert!(ambiguous.contains("compact"));
3963        assert!(!ambiguous.contains("deploy"));
3964
3965        // Without the reservation, a unique MCP prompt is left bare.
3966        let ambiguous = ambiguous_mcp_prompt_names([], ["compact", "deploy"]);
3967        assert!(ambiguous.is_empty());
3968
3969        // Two MCP prompts sharing a name are both qualified regardless of
3970        // reservation.
3971        let ambiguous = ambiguous_mcp_prompt_names([], ["dup", "dup", "unique"]);
3972        assert!(ambiguous.contains("dup"));
3973        assert!(!ambiguous.contains("unique"));
3974    }
3975
3976    #[test]
3977    fn test_qualified_compact_commands_are_not_native_compact() {
3978        let unqualified_blocks = [acp::ContentBlock::from("/compact")];
3979        let unqualified = Command::parse(&unqualified_blocks).unwrap();
3980        assert!(unqualified.is_unqualified("compact"));
3981
3982        let mcp_blocks = [acp::ContentBlock::from("/server.compact")];
3983        let mcp_qualified = Command::parse(&mcp_blocks).unwrap();
3984        assert_eq!(mcp_qualified.prompt_name, "compact");
3985        assert_eq!(mcp_qualified.explicit_server_id, Some("server"));
3986        assert!(!mcp_qualified.is_unqualified("compact"));
3987
3988        let skill_blocks = [acp::ContentBlock::from("/:compact")];
3989        let skill_qualified = Command::parse(&skill_blocks).unwrap();
3990        assert_eq!(skill_qualified.prompt_name, "compact");
3991        assert_eq!(skill_qualified.skill_scope, Some(""));
3992        assert!(!skill_qualified.is_unqualified("compact"));
3993    }
3994
3995    fn make_project_skill(name: &str, description: &str, worktree: &str) -> Skill {
3996        Skill {
3997            name: name.to_string(),
3998            description: description.to_string(),
3999            source: SkillSource::ProjectLocal {
4000                worktree_id: SkillScopeId(1),
4001                worktree_root_name: worktree.into(),
4002            },
4003            directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")),
4004            skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")),
4005            load_warnings: Vec::new(),
4006            disable_model_invocation: false,
4007            embedded_body: None,
4008        }
4009    }
4010
4011    fn make_builtin_skill(name: &str, description: &str) -> Skill {
4012        Skill {
4013            name: name.to_string(),
4014            description: description.to_string(),
4015            source: SkillSource::BuiltIn,
4016            directory_path: PathBuf::from(format!("/builtin/{name}")),
4017            skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")),
4018            load_warnings: Vec::new(),
4019            disable_model_invocation: false,
4020            embedded_body: Some("built-in body"),
4021        }
4022    }
4023
4024    /// Filter to only user-defined (non-built-in) skills for test assertions.
4025    fn user_skills(skills: &[Skill]) -> Vec<&Skill> {
4026        skills
4027            .iter()
4028            .filter(|s| !matches!(s.source, SkillSource::BuiltIn))
4029            .collect()
4030    }
4031
4032    #[test]
4033    fn test_combine_skills_keeps_every_entry_for_autocomplete() {
4034        // The autocomplete popup needs both same-named entries so the
4035        // source label can disambiguate them. `combine_skills` must not
4036        // drop the global when a project-local shares its name.
4037        let global = make_global_skill("review", "Global review");
4038        let project = make_project_skill("review", "Project review", "project");
4039
4040        let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter());
4041
4042        assert!(errors.is_empty());
4043        let user = user_skills(&skills);
4044        assert_eq!(user.len(), 2);
4045        assert!(matches!(user[0].source, SkillSource::Global));
4046        assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. }));
4047    }
4048
4049    #[test]
4050    fn test_apply_skill_overrides_project_wins_over_global() {
4051        // The model-facing projection collapses the same name to a
4052        // single entry, with the project-local winning. This is what
4053        // `select_catalog_skills`, `SkillTool`, and the slash-command
4054        // resolver all see.
4055        let global = make_global_skill("review", "Global review");
4056        let project = make_project_skill("review", "Project review", "project");
4057
4058        let resolved = apply_skill_overrides(&[global, project]);
4059
4060        assert_eq!(resolved.len(), 1);
4061        assert_eq!(resolved[0].description, "Project review");
4062        assert!(matches!(
4063            resolved[0].source,
4064            SkillSource::ProjectLocal { .. }
4065        ));
4066    }
4067
4068    #[test]
4069    fn test_apply_skill_overrides_same_source_collision_keeps_first() {
4070        // Two globals (or two project-locals from different worktrees)
4071        // colliding don't have a clear winner; preserve the historical
4072        // "first one wins" behavior.
4073        let first = make_global_skill("review", "First");
4074        let second = make_global_skill("review", "Second");
4075
4076        let resolved = apply_skill_overrides(&[first, second]);
4077
4078        assert_eq!(resolved.len(), 1);
4079        assert_eq!(resolved[0].description, "First");
4080    }
4081
4082    #[test]
4083    fn test_apply_skill_overrides_global_wins_over_builtin() {
4084        // A global skill with the same name as a built-in must shadow
4085        // the built-in in the model-facing projection, regardless of
4086        // iteration order.
4087        let built_in = make_builtin_skill("create-skill", "Built-in version");
4088        let global = make_global_skill("create-skill", "User override");
4089
4090        let resolved = apply_skill_overrides(&[built_in, global]);
4091
4092        assert_eq!(resolved.len(), 1);
4093        assert_eq!(resolved[0].description, "User override");
4094        assert!(matches!(resolved[0].source, SkillSource::Global));
4095    }
4096
4097    #[test]
4098    fn test_apply_skill_overrides_project_wins_over_builtin() {
4099        let built_in = make_builtin_skill("create-skill", "Built-in version");
4100        let project = make_project_skill("create-skill", "Project override", "my-project");
4101
4102        let resolved = apply_skill_overrides(&[built_in, project]);
4103
4104        assert_eq!(resolved.len(), 1);
4105        assert_eq!(resolved[0].description, "Project override");
4106        assert!(matches!(
4107            resolved[0].source,
4108            SkillSource::ProjectLocal { .. }
4109        ));
4110    }
4111
4112    #[test]
4113    fn test_apply_skill_overrides_project_wins_over_builtin_and_global() {
4114        // All three sources present — the project-local must win and
4115        // both lower-precedence entries must be dropped from the
4116        // model-facing projection.
4117        let built_in = make_builtin_skill("create-skill", "Built-in");
4118        let global = make_global_skill("create-skill", "Global");
4119        let project = make_project_skill("create-skill", "Project", "my-project");
4120
4121        let resolved = apply_skill_overrides(&[built_in, global, project]);
4122
4123        assert_eq!(resolved.len(), 1);
4124        assert_eq!(resolved[0].description, "Project");
4125    }
4126
4127    #[test]
4128    fn test_apply_skill_overrides_preserves_unique_skills() {
4129        let global_a = make_global_skill("alpha", "a");
4130        let global_b = make_global_skill("beta", "b");
4131        let project_c = make_project_skill("gamma", "c", "project");
4132
4133        let resolved = apply_skill_overrides(&[global_a, global_b, project_c]);
4134
4135        assert_eq!(resolved.len(), 3);
4136        let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
4137        assert_eq!(names, vec!["alpha", "beta", "gamma"]);
4138    }
4139
4140    #[test]
4141    fn test_skill_source_scope_prefix_and_matches_scope() {
4142        // The popup inserts `/<prefix>:<name>` using `scope_prefix`,
4143        // and the resolver routes via `matches_scope`. This test pins
4144        // the contract that the two stay in sync.
4145        let global = SkillSource::Global;
4146        // Globals use an empty prefix, so the popup inserts `/:<name>`.
4147        assert_eq!(global.scope_prefix(), "");
4148        assert!(global.matches_scope(""));
4149        // Hand-typed `/global:<name>` is not aliased to the global
4150        // source; it looks for a worktree literally named `global`.
4151        assert!(!global.matches_scope("global"));
4152        assert!(!global.matches_scope("zed"));
4153
4154        let project = SkillSource::ProjectLocal {
4155            worktree_id: SkillScopeId(1),
4156            worktree_root_name: "zed".into(),
4157        };
4158        // Project-local skills are scoped by their worktree root name
4159        // so multiple open worktrees with same-named skills can each
4160        // be addressed unambiguously.
4161        assert_eq!(project.scope_prefix(), "zed");
4162        assert!(project.matches_scope("zed"));
4163        // The empty scope is reserved for globals.
4164        assert!(!project.matches_scope(""));
4165        // An unrelated worktree name (or MCP server name) must not
4166        // match a project skill from a different worktree.
4167        assert!(!project.matches_scope("extensions"));
4168
4169        // A worktree literally named `global` is no longer ambiguous
4170        // with the global source: its skills are invoked as
4171        // `/global:<name>` while globals are invoked as `/:<name>`.
4172        let project_named_global = SkillSource::ProjectLocal {
4173            worktree_id: SkillScopeId(2),
4174            worktree_root_name: "global".into(),
4175        };
4176        assert_eq!(project_named_global.scope_prefix(), "global");
4177        assert!(project_named_global.matches_scope("global"));
4178        assert!(!project_named_global.matches_scope(""));
4179    }
4180
4181    #[test]
4182    fn test_select_catalog_skills_emits_issue_for_dropped_skills() {
4183        // Each skill's name + description occupies ~10KB. With a 50KB
4184        // budget, only the first ~5 visible skills fit; the rest must
4185        // appear as loading issues so the UI can surface them.
4186        let description = "x".repeat(10 * 1024);
4187        let mut skills = Vec::new();
4188        let total = 10;
4189        for i in 0..total {
4190            let name = format!("skill-{i:02}");
4191            skills.push(Skill {
4192                name: name.clone(),
4193                description: description.clone(),
4194                source: SkillSource::Global,
4195                directory_path: PathBuf::from(format!("/skills/{name}")),
4196                skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
4197                load_warnings: Vec::new(),
4198                disable_model_invocation: false,
4199                embedded_body: None,
4200            });
4201        }
4202
4203        let (kept, issues) = select_catalog_skills(&skills);
4204
4205        assert!(
4206            kept.len() < skills.len(),
4207            "some skills should be dropped due to the budget (kept {} of {})",
4208            kept.len(),
4209            skills.len(),
4210        );
4211        assert_eq!(
4212            issues.len(),
4213            1,
4214            "all dropped skills should be consolidated into a single issue, got {issues:?}",
4215        );
4216
4217        let kept_size: usize = kept
4218            .iter()
4219            .map(|s| s.name.len() + s.description.len())
4220            .sum();
4221        assert!(
4222            kept_size <= MAX_SKILL_DESCRIPTIONS_SIZE,
4223            "kept skills must fit in the budget (got {kept_size} bytes)",
4224        );
4225
4226        let issue = &issues[0];
4227        assert_eq!(issue.kind, SkillLoadingIssueKind::CatalogBudgetExceeded);
4228        assert!(
4229            issue.message.contains("50KB") && issue.message.contains("budget"),
4230            "issue message {:?} should describe the budget",
4231            issue.message,
4232        );
4233        assert_eq!(
4234            issue.path,
4235            skills[kept.len()].skill_file_path,
4236            "issue path should match the first dropped skill",
4237        );
4238
4239        for dropped_skill in &skills[kept.len()..total] {
4240            let name = &dropped_skill.name;
4241            assert!(
4242                issue.message.contains(name.as_str()),
4243                "issue message {:?} should mention the dropped skill name {name:?}",
4244                issue.message,
4245            );
4246            let bullet_line = format!("- {name}");
4247            assert!(
4248                issue
4249                    .message
4250                    .lines()
4251                    .any(|line| line.starts_with(&bullet_line)),
4252                "issue message {:?} should contain a bullet line starting with {bullet_line:?}",
4253                issue.message,
4254            );
4255        }
4256    }
4257
4258    #[test]
4259    fn test_select_catalog_skills_stops_packing_after_first_overflow() {
4260        // Once a model-invocable skill overflows the budget, no later
4261        // skills should be admitted, even if they're small enough to fit
4262        // in the remaining sliver. This keeps the cutoff deterministic by
4263        // sort order rather than dependent on individual skill sizes.
4264        let half_description = "a".repeat(MAX_SKILL_DESCRIPTIONS_SIZE / 2);
4265        let big_description = "b".repeat(MAX_SKILL_DESCRIPTIONS_SIZE);
4266        let small_description = "c".repeat(100);
4267
4268        let first = Skill {
4269            name: "skill-01-first".to_string(),
4270            description: half_description,
4271            source: SkillSource::Global,
4272            directory_path: PathBuf::from("/skills/skill-01-first"),
4273            skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"),
4274            load_warnings: Vec::new(),
4275            disable_model_invocation: false,
4276            embedded_body: None,
4277        };
4278        let second = Skill {
4279            name: "skill-02-overflows".to_string(),
4280            description: big_description,
4281            source: SkillSource::Global,
4282            directory_path: PathBuf::from("/skills/skill-02-overflows"),
4283            skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"),
4284            load_warnings: Vec::new(),
4285            disable_model_invocation: false,
4286            embedded_body: None,
4287        };
4288        let third = Skill {
4289            name: "skill-03-would-fit".to_string(),
4290            description: small_description,
4291            source: SkillSource::Global,
4292            directory_path: PathBuf::from("/skills/skill-03-would-fit"),
4293            skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"),
4294            load_warnings: Vec::new(),
4295            disable_model_invocation: false,
4296            embedded_body: None,
4297        };
4298
4299        // Sanity-check the test setup: the third skill is small enough
4300        // that a greedy packer would have squeezed it in alongside the
4301        // first one.
4302        let leftover_after_first =
4303            MAX_SKILL_DESCRIPTIONS_SIZE - (first.name.len() + first.description.len());
4304        assert!(
4305            third.name.len() + third.description.len() <= leftover_after_first,
4306            "third skill must fit in the leftover sliver for this test to be meaningful",
4307        );
4308
4309        let skills = vec![first.clone(), second.clone(), third.clone()];
4310        let (kept, issues) = select_catalog_skills(&skills);
4311
4312        let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
4313        assert_eq!(kept_names, vec![first.name.as_str()]);
4314
4315        assert_eq!(issues.len(), 1, "expected a single consolidated issue");
4316        assert_eq!(issues[0].kind, SkillLoadingIssueKind::CatalogBudgetExceeded);
4317        assert_eq!(issues[0].path, second.skill_file_path);
4318        assert!(
4319            issues[0].message.contains(second.name.as_str()),
4320            "issue message {:?} should mention {:?}",
4321            issues[0].message,
4322            second.name,
4323        );
4324        assert!(
4325            issues[0].message.contains(third.name.as_str()),
4326            "issue message {:?} should mention {:?}",
4327            issues[0].message,
4328            third.name,
4329        );
4330        assert!(
4331            issues[0].message.contains("- "),
4332            "issue message {:?} should use bullet form when multiple skills are dropped",
4333            issues[0].message,
4334        );
4335    }
4336
4337    #[test]
4338    fn test_select_catalog_skills_excludes_hidden_skills_from_catalog() {
4339        // Hidden skills (`disable_model_invocation: true`) are slash-only and
4340        // must not appear in the catalog returned by `select_catalog_skills`,
4341        // even when they would otherwise fit in the budget. They also don't
4342        // count against the budget, so a hidden skill larger than the entire
4343        // budget shouldn't generate a loading issue or prevent later visible
4344        // skills from fitting.
4345        let huge_description = "y".repeat(MAX_SKILL_DESCRIPTIONS_SIZE * 2);
4346        let hidden = Skill {
4347            name: "hidden-huge".to_string(),
4348            description: huge_description,
4349            source: SkillSource::Global,
4350            directory_path: PathBuf::from("/skills/hidden-huge"),
4351            skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"),
4352            load_warnings: Vec::new(),
4353            disable_model_invocation: true,
4354            embedded_body: None,
4355        };
4356        let visible = Skill {
4357            name: "visible".to_string(),
4358            description: "short".to_string(),
4359            source: SkillSource::Global,
4360            directory_path: PathBuf::from("/skills/visible"),
4361            skill_file_path: PathBuf::from("/skills/visible/SKILL.md"),
4362            load_warnings: Vec::new(),
4363            disable_model_invocation: false,
4364            embedded_body: None,
4365        };
4366
4367        let (kept, issues) = select_catalog_skills(&[hidden, visible]);
4368
4369        assert!(issues.is_empty(), "expected no issues, got: {issues:?}");
4370        let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
4371        assert_eq!(kept_names, vec!["visible"]);
4372    }
4373
4374    #[gpui::test]
4375    async fn test_maintaining_project_context(cx: &mut TestAppContext) {
4376        init_test(cx);
4377        let fs = FakeFs::new(cx.executor());
4378        fs.insert_tree(
4379            "/",
4380            json!({
4381                "a": {}
4382            }),
4383        )
4384        .await;
4385        let project = Project::test(fs.clone(), [], cx).await;
4386        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4387        let agent =
4388            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4389
4390        // Creating a session registers the project and triggers context building.
4391        let connection = NativeAgentConnection(agent.clone());
4392        let _acp_thread = cx
4393            .update(|cx| {
4394                Rc::new(connection).new_session(
4395                    project.clone(),
4396                    PathList::new(&[Path::new("/")]),
4397                    cx,
4398                )
4399            })
4400            .await
4401            .unwrap();
4402        cx.run_until_parked();
4403
4404        let thread = agent.read_with(cx, |agent, _cx| {
4405            agent.sessions.values().next().unwrap().thread.clone()
4406        });
4407
4408        agent.read_with(cx, |agent, cx| {
4409            let project_id = project.entity_id();
4410            let state = agent.projects.get(&project_id).unwrap();
4411            assert_eq!(state.project_context.read(cx).worktrees, vec![]);
4412            assert_eq!(thread.read(cx).project_context().read(cx).worktrees, vec![]);
4413        });
4414
4415        let worktree = project
4416            .update(cx, |project, cx| project.create_worktree("/a", true, cx))
4417            .await
4418            .unwrap();
4419        cx.run_until_parked();
4420        agent.read_with(cx, |agent, cx| {
4421            let project_id = project.entity_id();
4422            let state = agent.projects.get(&project_id).unwrap();
4423            let expected_worktrees = vec![WorktreeContext {
4424                root_name: "a".into(),
4425                abs_path: Path::new("/a").into(),
4426                rules_file: None,
4427            }];
4428            assert_eq!(state.project_context.read(cx).worktrees, expected_worktrees);
4429            assert_eq!(
4430                thread.read(cx).project_context().read(cx).worktrees,
4431                expected_worktrees
4432            );
4433        });
4434
4435        // Creating `/a/.rules` updates the project context.
4436        fs.insert_file("/a/.rules", Vec::new()).await;
4437        cx.run_until_parked();
4438        agent.read_with(cx, |agent, cx| {
4439            let project_id = project.entity_id();
4440            let state = agent.projects.get(&project_id).unwrap();
4441            let rules_entry = worktree
4442                .read(cx)
4443                .entry_for_path(rel_path(".rules"))
4444                .unwrap();
4445            let expected_worktrees = vec![WorktreeContext {
4446                root_name: "a".into(),
4447                abs_path: Path::new("/a").into(),
4448                rules_file: Some(RulesFileContext {
4449                    path_in_worktree: rel_path(".rules").into(),
4450                    text: "".into(),
4451                    project_entry_id: rules_entry.id.to_usize(),
4452                }),
4453            }];
4454            assert_eq!(state.project_context.read(cx).worktrees, expected_worktrees);
4455            assert_eq!(
4456                thread.read(cx).project_context().read(cx).worktrees,
4457                expected_worktrees
4458            );
4459        });
4460    }
4461
4462    #[gpui::test]
4463    async fn test_global_skills_load_and_reload(cx: &mut TestAppContext) {
4464        init_test(cx);
4465        let fs = FakeFs::new(cx.executor());
4466        let skills_dir = global_skills_dir();
4467        let initial_skill_dir = skills_dir.join("my-skill");
4468        let initial_skill_path = initial_skill_dir.join("SKILL.md");
4469        fs.create_dir(&initial_skill_dir).await.unwrap();
4470        fs.insert_file(
4471            &initial_skill_path,
4472            b"---\nname: my-skill\ndescription: First version\n---\n\nbody-v1".to_vec(),
4473        )
4474        .await;
4475
4476        let project = Project::test(fs.clone(), [], cx).await;
4477        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4478        let agent =
4479            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4480
4481        // Simulate the user-interaction trigger that the agent panel
4482        // fires (input focus, slash autocomplete, or submit). In tests
4483        // we call it directly because there's no panel.
4484        cx.update(|cx| {
4485            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4486        });
4487
4488        let connection = NativeAgentConnection(agent.clone());
4489        let _acp_thread = cx
4490            .update(|cx| {
4491                Rc::new(connection).new_session(
4492                    project.clone(),
4493                    PathList::new(&[Path::new("/")]),
4494                    cx,
4495                )
4496            })
4497            .await
4498            .unwrap();
4499        cx.run_until_parked();
4500
4501        // The pre-existing skill should be loaded into the project state.
4502        agent.read_with(cx, |agent, _cx| {
4503            let state = agent.projects.get(&project.entity_id()).unwrap();
4504            let user = user_skills(&state.skills);
4505            assert_eq!(user.len(), 1);
4506            assert_eq!(user[0].name, "my-skill");
4507            assert_eq!(user[0].description, "First version");
4508        });
4509
4510        // Modify the SKILL.md and verify the project context refreshes.
4511        fs.write(
4512            &initial_skill_path,
4513            b"---\nname: my-skill\ndescription: Second version\n---\n\nbody-v2",
4514        )
4515        .await
4516        .unwrap();
4517        cx.run_until_parked();
4518
4519        agent.read_with(cx, |agent, _cx| {
4520            let state = agent.projects.get(&project.entity_id()).unwrap();
4521            let user = user_skills(&state.skills);
4522            assert_eq!(user.len(), 1);
4523            assert_eq!(user[0].description, "Second version");
4524        });
4525    }
4526
4527    #[gpui::test]
4528    async fn test_global_skill_with_long_description_loads_with_warning(cx: &mut TestAppContext) {
4529        init_test(cx);
4530        let fs = FakeFs::new(cx.executor());
4531        let skills_dir = global_skills_dir();
4532        let skill_dir = skills_dir.join("long-description");
4533        let skill_path = skill_dir.join("SKILL.md");
4534        let long_description = "a".repeat(agent_skills::MAX_SKILL_DESCRIPTION_LEN + 1);
4535        fs.create_dir(&skill_dir).await.unwrap();
4536        fs.insert_file(
4537            &skill_path,
4538            format!("---\nname: long-description\ndescription: {long_description}\n---\n\nbody")
4539                .into_bytes(),
4540        )
4541        .await;
4542
4543        let project = Project::test(fs.clone(), [], cx).await;
4544        let project_id = project.entity_id();
4545        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4546        let agent =
4547            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4548
4549        cx.update(|cx| {
4550            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4551        });
4552
4553        let connection = NativeAgentConnection(agent.clone());
4554        let acp_thread = cx
4555            .update(|cx| {
4556                Rc::new(connection.clone()).new_session(
4557                    project.clone(),
4558                    PathList::new(&[Path::new("/")]),
4559                    cx,
4560                )
4561            })
4562            .await
4563            .unwrap();
4564        cx.run_until_parked();
4565
4566        let loaded_skill = agent.read_with(cx, |agent, cx| {
4567            let state = agent.projects.get(&project_id).unwrap();
4568            let user = user_skills(&state.skills);
4569            assert_eq!(user.len(), 1);
4570            assert_eq!(user[0].name, "long-description");
4571            assert_eq!(user[0].description, long_description);
4572
4573            let catalog_names: Vec<&str> = state
4574                .project_context
4575                .read(cx)
4576                .skills()
4577                .iter()
4578                .map(|skill| skill.name.as_str())
4579                .collect();
4580            assert!(
4581                catalog_names.contains(&"long-description"),
4582                "long-description skill should remain in the model catalog: {catalog_names:?}"
4583            );
4584
4585            assert!(
4586                state.skill_loading_issues.iter().any(|issue| {
4587                    issue.kind == SkillLoadingIssueKind::DescriptionTooLong
4588                        && issue.path == skill_path
4589                        && issue.message.to_string().contains("1024-byte limit")
4590                }),
4591                "expected a description-length warning issue, got {:?}",
4592                state.skill_loading_issues
4593            );
4594
4595            (*user[0]).clone()
4596        });
4597
4598        let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
4599        cx.update(|cx| {
4600            let available_skills = connection.available_skills(&session_id, cx);
4601            let available_skill = available_skills
4602                .iter()
4603                .find(|skill| skill.name == "long-description")
4604                .expect("long-description should appear in available skills");
4605            assert_eq!(available_skill.description, long_description);
4606            assert!(
4607                available_skill
4608                    .warning
4609                    .as_ref()
4610                    .is_some_and(|warning| warning.contains("1024-byte limit")),
4611                "available skill should expose warning text, got {:?}",
4612                available_skill.warning
4613            );
4614        });
4615
4616        let body = agent_skills::read_skill_body(fs.as_ref(), &loaded_skill.skill_file_path)
4617            .await
4618            .expect("body should load despite description-length warning");
4619        assert_eq!(body, "body");
4620    }
4621
4622    #[gpui::test]
4623    async fn test_symlinked_global_skills_load_and_reload(cx: &mut TestAppContext) {
4624        init_test(cx);
4625        let fs = FakeFs::new(cx.executor());
4626        let skills_dir = global_skills_dir();
4627        let external_skill_dir = PathBuf::from(path!("/external/my-skill"));
4628        let skill_link_dir = skills_dir.join("my-skill");
4629        let skill_link_path = skill_link_dir.join("SKILL.md");
4630
4631        fs.insert_tree(
4632            &external_skill_dir,
4633            json!({
4634                "SKILL.md": "---\nname: my-skill\ndescription: First symlinked version\n---\n\nbody-v1"
4635            }),
4636        )
4637        .await;
4638        fs.create_dir(&skills_dir).await.unwrap();
4639        fs.create_symlink(&skill_link_dir, external_skill_dir)
4640            .await
4641            .unwrap();
4642
4643        let project = Project::test(fs.clone(), [], cx).await;
4644        let project_id = project.entity_id();
4645        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4646        let agent =
4647            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4648
4649        cx.update(|cx| {
4650            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4651        });
4652
4653        let connection = NativeAgentConnection(agent.clone());
4654        let _acp_thread = cx
4655            .update(|cx| {
4656                Rc::new(connection).new_session(
4657                    project.clone(),
4658                    PathList::new(&[Path::new("/")]),
4659                    cx,
4660                )
4661            })
4662            .await
4663            .unwrap();
4664        cx.run_until_parked();
4665
4666        let loaded_skill = agent.read_with(cx, |agent, cx| {
4667            let state = agent.projects.get(&project_id).unwrap();
4668            let user = user_skills(&state.skills);
4669            assert_eq!(user.len(), 1);
4670            assert_eq!(user[0].name, "my-skill");
4671            assert_eq!(user[0].description, "First symlinked version");
4672            assert_eq!(user[0].source, SkillSource::Global);
4673            assert_eq!(user[0].skill_file_path, skill_link_path);
4674
4675            let catalog_skills = state.project_context.read(cx).skills();
4676            let catalog_skill = catalog_skills
4677                .iter()
4678                .find(|skill| skill.name == "my-skill")
4679                .expect("symlinked skill should be included in the model-facing catalog");
4680            assert_eq!(catalog_skill.description, "First symlinked version");
4681            assert_eq!(
4682                catalog_skill.location,
4683                skill_link_path.to_string_lossy().as_ref()
4684            );
4685
4686            (*user[0]).clone()
4687        });
4688        let body = agent_skills::read_skill_body(fs.as_ref(), &loaded_skill.skill_file_path)
4689            .await
4690            .unwrap();
4691        assert_eq!(body, "body-v1");
4692
4693        fs.write(
4694            &skill_link_path,
4695            b"---\nname: my-skill\ndescription: Second symlinked version\n---\n\nbody-v2",
4696        )
4697        .await
4698        .unwrap();
4699        cx.run_until_parked();
4700
4701        let reloaded_skill = agent.read_with(cx, |agent, cx| {
4702            let state = agent.projects.get(&project_id).unwrap();
4703            let user = user_skills(&state.skills);
4704            assert_eq!(user.len(), 1);
4705            assert_eq!(user[0].name, "my-skill");
4706            assert_eq!(user[0].description, "Second symlinked version");
4707            assert_eq!(user[0].source, SkillSource::Global);
4708            assert_eq!(user[0].skill_file_path, skill_link_path);
4709
4710            let catalog_skills = state.project_context.read(cx).skills();
4711            let catalog_skill = catalog_skills
4712                .iter()
4713                .find(|skill| skill.name == "my-skill")
4714                .expect("reloaded symlinked skill should be included in the model-facing catalog");
4715            assert_eq!(catalog_skill.description, "Second symlinked version");
4716            assert_eq!(
4717                catalog_skill.location,
4718                skill_link_path.to_string_lossy().as_ref()
4719            );
4720
4721            (*user[0]).clone()
4722        });
4723        let body = agent_skills::read_skill_body(fs.as_ref(), &reloaded_skill.skill_file_path)
4724            .await
4725            .unwrap();
4726        assert_eq!(body, "body-v2");
4727    }
4728
4729    #[gpui::test]
4730    async fn test_global_skills_dir_created_after_startup(cx: &mut TestAppContext) {
4731        init_test(cx);
4732        let fs = FakeFs::new(cx.executor());
4733        let skills_dir = global_skills_dir();
4734
4735        // Intentionally do NOT pre-create `skills_dir`. The first scan
4736        // trigger should find no directory and leave the watch state
4737        // idle; a later trigger after the directory is created should
4738        // attach to the deepest existing ancestor and react when the
4739        // directory is created later.
4740
4741        let project = Project::test(fs.clone(), [], cx).await;
4742        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4743        let agent =
4744            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4745
4746        // First scan trigger: nothing on disk yet, state stays idle.
4747        cx.update(|cx| {
4748            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4749        });
4750
4751        let connection = NativeAgentConnection(agent.clone());
4752        let _acp_thread = cx
4753            .update(|cx| {
4754                Rc::new(connection).new_session(
4755                    project.clone(),
4756                    PathList::new(&[Path::new("/")]),
4757                    cx,
4758                )
4759            })
4760            .await
4761            .unwrap();
4762        cx.run_until_parked();
4763
4764        // No skills directory exists yet, so no skills should be loaded.
4765        agent.read_with(cx, |agent, _cx| {
4766            let state = agent.projects.get(&project.entity_id()).unwrap();
4767            assert!(
4768                user_skills(&state.skills).is_empty(),
4769                "expected no user skills before the global skills dir exists, got {:?}",
4770                state.skills
4771            );
4772        });
4773
4774        // Create the global skills directory and a skill within it.
4775        let new_skill_dir = skills_dir.join("late-skill");
4776        fs.create_dir(&new_skill_dir).await.unwrap();
4777        fs.insert_file(
4778            &new_skill_dir.join("SKILL.md"),
4779            b"---\nname: late-skill\ndescription: Created after startup\n---\n\nbody".to_vec(),
4780        )
4781        .await;
4782
4783        // Fire the trigger again, simulating the user interacting with
4784        // the agent panel after creating the skills directory. The
4785        // second scan should find the directory and start the watch,
4786        // which refreshes project context.
4787        cx.update(|cx| {
4788            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4789        });
4790        cx.run_until_parked();
4791
4792        agent.read_with(cx, |agent, _cx| {
4793            let state = agent.projects.get(&project.entity_id()).unwrap();
4794            let user = user_skills(&state.skills);
4795            assert_eq!(user.len(), 1);
4796            assert_eq!(user[0].name, "late-skill");
4797            assert_eq!(user[0].description, "Created after startup");
4798        });
4799    }
4800
4801    /// Regression test for the case where a skill is added (e.g. by the
4802    /// SKILL.md file watcher) AFTER a session is registered. The system
4803    /// prompt and slash-command list both read live state, so they pick
4804    /// up the new skill automatically. The `SkillTool` registered on the
4805    /// thread used to hold a stale snapshot of `state.skills` taken at
4806    /// thread-construction time, which meant the model would see the new
4807    /// skill in `<available_skills>` but get "not found" when it tried to
4808    /// invoke it. The fix wires the tool to a dynamic resolver closure
4809    /// that re-reads `state.skills` for the project on every invocation.
4810    #[gpui::test]
4811    async fn test_skills_added_after_session_visible_to_skill_tool(cx: &mut TestAppContext) {
4812        init_test(cx);
4813        let fs = FakeFs::new(cx.executor());
4814        let skills_dir = global_skills_dir();
4815
4816        // No skills directory exists at startup; the watcher should
4817        // create one and pick up SKILL.md when it's added later.
4818        let project = Project::test(fs.clone(), [], cx).await;
4819        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4820        let agent =
4821            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4822
4823        // First scan trigger: nothing on disk yet.
4824        cx.update(|cx| {
4825            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4826        });
4827
4828        let connection = NativeAgentConnection(agent.clone());
4829        let _acp_thread = cx
4830            .update(|cx| {
4831                Rc::new(connection).new_session(
4832                    project.clone(),
4833                    PathList::new(&[Path::new("/")]),
4834                    cx,
4835                )
4836            })
4837            .await
4838            .unwrap();
4839        cx.run_until_parked();
4840
4841        let project_id = project.entity_id();
4842        agent.read_with(cx, |agent, _cx| {
4843            let state = agent.projects.get(&project_id).unwrap();
4844            assert!(
4845                user_skills(&state.skills).is_empty(),
4846                "expected no user skills before the global skills dir exists, got {:?}",
4847                state.skills
4848            );
4849        });
4850
4851        // Build the same resolver closure that `register_session` uses.
4852        // This is the production resolver factored into a helper so the
4853        // test can verify resolution behavior directly without setting
4854        // up the full tool-call plumbing (`ToolInput`,
4855        // `ToolCallEventStream`, authorization channel, ...).
4856        let resolve =
4857            cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
4858
4859        // Sanity check: before any skills exist, the resolver returns an
4860        // empty list — NOT the snapshot that `Thread::new` would have
4861        // captured.
4862        cx.update(|cx| {
4863            let all = resolve(cx);
4864            let user: Vec<_> = all
4865                .iter()
4866                .filter(|s| !matches!(s.source, SkillSource::BuiltIn))
4867                .collect();
4868            assert!(user.is_empty());
4869        });
4870
4871        // Now create a SKILL.md AFTER the session was registered. With
4872        // the old code this would be invisible to the `SkillTool`
4873        // because the tool held an `Arc<Vec<Skill>>` snapshot taken at
4874        // thread construction time.
4875        let new_skill_dir = skills_dir.join("my-skill");
4876        fs.create_dir(&new_skill_dir).await.unwrap();
4877        fs.insert_file(
4878            &new_skill_dir.join("SKILL.md"),
4879            b"---\nname: my-skill\ndescription: Created after session\n---\n\nbody".to_vec(),
4880        )
4881        .await;
4882
4883        // Second scan trigger: now the directory exists, so the scan
4884        // starts the watch and refreshes project context.
4885        cx.update(|cx| {
4886            agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
4887        });
4888        cx.run_until_parked();
4889
4890        // `state.skills` reflects the new skill (the watcher ran).
4891        agent.read_with(cx, |agent, _cx| {
4892            let state = agent.projects.get(&project_id).unwrap();
4893            let user = user_skills(&state.skills);
4894            assert_eq!(user.len(), 1);
4895            assert_eq!(user[0].name, "my-skill");
4896        });
4897
4898        // The resolver the `SkillTool` uses must see it too. This is the
4899        // crux of the regression test: the tool's view of skills is
4900        // resolved at invocation time, not at thread-construction time.
4901        cx.update(|cx| {
4902            let all = resolve(cx);
4903            let snapshot: Vec<_> = all
4904                .iter()
4905                .filter(|s| !matches!(s.source, SkillSource::BuiltIn))
4906                .collect();
4907            assert_eq!(
4908                snapshot.len(),
4909                1,
4910                "dynamic resolver should see the new skill"
4911            );
4912            assert_eq!(snapshot[0].name, "my-skill");
4913            assert_eq!(snapshot[0].description, "Created after session");
4914        });
4915
4916        // And rendering the envelope through the same path the tool uses
4917        // produces a `<skill_content name="my-skill">` block, confirming
4918        // the model would see the new skill if it invoked the tool.
4919        let skill_for_render = cx.update(|cx| {
4920            let snapshot = resolve(cx);
4921            snapshot
4922                .iter()
4923                .find(|s| s.name == "my-skill" && !s.disable_model_invocation)
4924                .cloned()
4925                .expect("my-skill should be model-invocable")
4926        });
4927        let body = agent_skills::read_skill_body(fs.as_ref(), &skill_for_render.skill_file_path)
4928            .await
4929            .expect("skill body should load");
4930        let rendered = render_skill_envelope(&skill_for_render, &body);
4931        assert!(
4932            rendered.contains("<skill_content name=\"my-skill\">"),
4933            "rendered envelope missing skill_content tag: {rendered}"
4934        );
4935    }
4936
4937    /// Subagents must inherit access to the same skills as their parent.
4938    /// Production wires this up in `NativeThreadEnvironment::create_subagent_thread`,
4939    /// which calls `agent.register_session(subagent, project_id, ...)` —
4940    /// `register_session` is what installs the `SkillTool` on the thread
4941    /// using a resolver closure keyed on `project_id`. Because the
4942    /// subagent shares its parent's `project_id`, both threads end up
4943    /// resolving skills against the same `state.skills`.
4944    ///
4945    /// This test exercises that production path directly: it creates a
4946    /// parent session via the agent connection, builds a subagent thread
4947    /// the same way `create_subagent_thread` does, and runs it through
4948    /// `register_session`. It then asserts that the `SkillTool` is
4949    /// registered on the subagent thread and that resolving against the
4950    /// same `project_id` produces the same skill set the parent sees.
4951    #[gpui::test]
4952    async fn test_subagent_skills_lookup_matches_parent(cx: &mut TestAppContext) {
4953        init_test(cx);
4954        let fs = FakeFs::new(cx.executor());
4955        let skills_dir = global_skills_dir();
4956        let skill_dir = skills_dir.join("shared-skill");
4957        fs.create_dir(&skill_dir).await.unwrap();
4958        fs.insert_file(
4959            &skill_dir.join("SKILL.md"),
4960            b"---\nname: shared-skill\ndescription: A shared skill\n---\n\nbody".to_vec(),
4961        )
4962        .await;
4963
4964        let project = Project::test(fs.clone(), [], cx).await;
4965        let thread_store = cx.new(|cx| ThreadStore::new(cx));
4966        let agent =
4967            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
4968
4969        // Open a parent session through the connection, the same way
4970        // production does. This triggers project-context refresh which
4971        // populates `state.skills` for the project.
4972        let connection = NativeAgentConnection(agent.clone());
4973        let _parent_acp = cx
4974            .update(|cx| {
4975                Rc::new(connection).new_session(
4976                    project.clone(),
4977                    PathList::new(&[Path::new("/")]),
4978                    cx,
4979                )
4980            })
4981            .await
4982            .unwrap();
4983        cx.run_until_parked();
4984
4985        let project_id = project.entity_id();
4986
4987        // Sanity check: resolving against the parent's project sees the skill.
4988        let parent_resolve =
4989            cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
4990        cx.update(|cx| {
4991            let all = parent_resolve(cx);
4992            let parent_skills: Vec<_> = all
4993                .iter()
4994                .filter(|s| !matches!(s.source, SkillSource::BuiltIn))
4995                .collect();
4996            assert_eq!(parent_skills.len(), 1);
4997            assert_eq!(parent_skills[0].name, "shared-skill");
4998        });
4999
5000        // Grab the parent thread out of the agent's session map. This
5001        // mirrors what `create_subagent_thread` does internally — it
5002        // looks up the parent session by `parent_session_id` and reads
5003        // its `project_id` to forward to `register_session`.
5004        let (parent_thread, parent_project_id) = agent.read_with(cx, |agent, _cx| {
5005            let session = agent
5006                .sessions
5007                .values()
5008                .next()
5009                .expect("parent session should exist");
5010            (session.thread.clone(), session.project_id)
5011        });
5012        assert_eq!(parent_project_id, project_id);
5013
5014        // Build the subagent thread the same way
5015        // `NativeThreadEnvironment::create_subagent_thread` does.
5016        let subagent_thread = cx.update(|cx| cx.new(|cx| Thread::new_subagent(&parent_thread, cx)));
5017
5018        // Run the subagent through the production registration path.
5019        // This is what installs the `SkillTool` on the thread.
5020        let _subagent_acp = agent.update(cx, |agent, cx| {
5021            agent.register_session(subagent_thread.clone(), parent_project_id, 1, cx)
5022        });
5023
5024        // Verify the subagent thread has the `SkillTool` installed —
5025        // without `register_session`, it would not.
5026        subagent_thread.read_with(cx, |thread, _cx| {
5027            assert!(thread.is_subagent());
5028            assert!(
5029                thread.has_registered_tool(SkillTool::NAME),
5030                "subagent should have SkillTool registered after register_session"
5031            );
5032        });
5033
5034        // The subagent's `SkillTool` is wired to a resolver closure keyed
5035        // on the same `project_id` the parent used, so it sees the same
5036        // skill set. We check this by constructing an equivalent resolver
5037        // against the same project_id and asserting it matches.
5038        let subagent_resolve = cx
5039            .update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id));
5040        cx.update(|cx| {
5041            let all = subagent_resolve(cx);
5042            let subagent_skills: Vec<_> = all
5043                .iter()
5044                .filter(|s| !matches!(s.source, SkillSource::BuiltIn))
5045                .collect();
5046            assert_eq!(subagent_skills.len(), 1);
5047            assert_eq!(subagent_skills[0].name, "shared-skill");
5048        });
5049    }
5050
5051    #[gpui::test]
5052    async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) {
5053        init_test(cx);
5054        let fs = FakeFs::new(cx.executor());
5055        let skills_dir = global_skills_dir();
5056
5057        // Two skills: one model-invocable (default), one slash-only via
5058        // `disable-model-invocation: true`. Both should still appear in
5059        // the slash menu as first-class skills.
5060        let visible_dir = skills_dir.join("visible-skill");
5061        fs.create_dir(&visible_dir).await.unwrap();
5062        fs.insert_file(
5063            &visible_dir.join("SKILL.md"),
5064            b"---\nname: visible-skill\ndescription: Visible skill\n---\n\nbody".to_vec(),
5065        )
5066        .await;
5067
5068        let hidden_dir = skills_dir.join("deploy");
5069        fs.create_dir(&hidden_dir).await.unwrap();
5070        fs.insert_file(
5071            &hidden_dir.join("SKILL.md"),
5072            b"---\nname: deploy\ndescription: Deploy to prod\ndisable-model-invocation: true\n---\n\nbody"
5073                .to_vec(),
5074        )
5075        .await;
5076
5077        let project = Project::test(fs.clone(), [], cx).await;
5078        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5079        let agent =
5080            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5081
5082        let connection = NativeAgentConnection(agent.clone());
5083        let acp_thread = cx
5084            .update(|cx| {
5085                Rc::new(connection.clone()).new_session(
5086                    project.clone(),
5087                    PathList::new(&[Path::new("/")]),
5088                    cx,
5089                )
5090            })
5091            .await
5092            .unwrap();
5093        cx.run_until_parked();
5094
5095        let project_id = project.entity_id();
5096        let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
5097
5098        agent.read_with(cx, |agent, cx| {
5099            let commands = NativeAgent::build_available_commands_for_project(
5100                agent.projects.get(&project_id),
5101                cx,
5102            );
5103            let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
5104            assert!(
5105                !names.contains(&"visible-skill"),
5106                "skills should not be exposed as ACP slash commands: {names:?}"
5107            );
5108            assert!(
5109                !names.contains(&"deploy"),
5110                "slash-only skills should not be exposed as ACP slash commands: {names:?}"
5111            );
5112        });
5113
5114        cx.update(|cx| {
5115            let skills = connection.available_skills(&session_id, cx);
5116            let names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
5117            assert!(
5118                names.contains(&"visible-skill"),
5119                "visible skill missing from available skills: {names:?}"
5120            );
5121            assert!(
5122                names.contains(&"deploy"),
5123                "slash-only skill missing from available skills: {names:?}"
5124            );
5125        });
5126
5127        // The model's catalog (ProjectContext.skills) should NOT include
5128        // `deploy` since it has disable_model_invocation set.
5129        agent.read_with(cx, |agent, cx| {
5130            let state = agent.projects.get(&project_id).unwrap();
5131            let catalog: Vec<&str> = state
5132                .project_context
5133                .read(cx)
5134                .skills()
5135                .iter()
5136                .map(|s| s.name.as_str())
5137                .collect();
5138            assert!(
5139                catalog.contains(&"visible-skill"),
5140                "visible skill missing from catalog: {catalog:?}"
5141            );
5142            assert!(
5143                !catalog.contains(&"deploy"),
5144                "deploy should be excluded from catalog: {catalog:?}"
5145            );
5146        });
5147    }
5148
5149    #[gpui::test]
5150    async fn test_project_skills_require_worktree_trust(cx: &mut TestAppContext) {
5151        use collections::{HashMap, HashSet};
5152        use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees};
5153
5154        init_test(cx);
5155        cx.update(|cx| {
5156            // The trust global isn't created by `init_test`. We need it
5157            // for `Project::test_with_worktree_trust` to actually wire up
5158            // trust tracking and for our subscription in
5159            // `register_project_with_initial_context` to fire.
5160            trusted_worktrees::init(HashMap::default(), cx);
5161        });
5162
5163        let fs = FakeFs::new(cx.executor());
5164        fs.insert_tree(
5165            "/project",
5166            json!({
5167                ".agents": {
5168                    "skills": {
5169                        "my-skill": {
5170                            "SKILL.md": "---\nname: my-skill\ndescription: A project skill\n---\n\nbody"
5171                        }
5172                    }
5173                }
5174            }),
5175        )
5176        .await;
5177
5178        // `test_with_worktree_trust` initializes the trust system and
5179        // starts every worktree as restricted, mirroring production
5180        // behavior on a freshly opened folder.
5181        let project =
5182            Project::test_with_worktree_trust(fs.clone(), [Path::new("/project")], cx).await;
5183        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5184        let agent =
5185            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5186
5187        let connection = NativeAgentConnection(agent.clone());
5188        let acp_thread = cx
5189            .update(|cx| {
5190                Rc::new(connection.clone()).new_session(
5191                    project.clone(),
5192                    PathList::new(&[Path::new("/project")]),
5193                    cx,
5194                )
5195            })
5196            .await
5197            .unwrap();
5198        cx.run_until_parked();
5199
5200        let project_id = project.entity_id();
5201        let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
5202        let worktree_id = project.read_with(cx, |project, cx| {
5203            project.worktrees(cx).next().unwrap().read(cx).id()
5204        });
5205
5206        // Untrusted: project skills are excluded from the loaded list and
5207        // never make it into the catalog or slash commands.
5208        agent.read_with(cx, |agent, cx| {
5209            let state = agent.projects.get(&project_id).unwrap();
5210            assert!(
5211                user_skills(&state.skills).is_empty(),
5212                "untrusted worktree skills should not load: {:?}",
5213                state
5214                    .skills
5215                    .iter()
5216                    .map(|s| s.name.as_str())
5217                    .collect::<Vec<_>>()
5218            );
5219            let commands = NativeAgent::build_available_commands_for_project(Some(state), cx);
5220            let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
5221            assert!(
5222                !names.contains(&"my-skill"),
5223                "untrusted skill leaked into slash commands: {names:?}"
5224            );
5225        });
5226
5227        // Granting trust should trigger a context refresh; the skill then
5228        // appears in both the catalog and the slash-command list.
5229        cx.update(|cx| {
5230            let trusted_worktrees = TrustedWorktrees::try_get_global(cx)
5231                .expect("trusted worktrees global initialized by test_with_worktree_trust");
5232            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
5233                trusted_worktrees.trust(
5234                    &project.read(cx).worktree_store(),
5235                    HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
5236                    cx,
5237                );
5238            });
5239        });
5240        cx.run_until_parked();
5241
5242        agent.read_with(cx, |agent, _cx| {
5243            let state = agent.projects.get(&project_id).unwrap();
5244            let user = user_skills(&state.skills);
5245            let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect();
5246            assert_eq!(names, vec!["my-skill"]);
5247        });
5248
5249        cx.update(|cx| {
5250            let skills = connection.available_skills(&session_id, cx);
5251            let skill_names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
5252            assert!(
5253                skill_names.contains(&"my-skill"),
5254                "trusted skill should appear in available skills: {skill_names:?}"
5255            );
5256        });
5257    }
5258
5259    /// Open a session against a freshly created project and trust its only
5260    /// worktree, so project-local skills load. Returns the agent, the
5261    /// project, and the worktree id of the project root.
5262    async fn open_trusted_project_skills(
5263        cx: &mut TestAppContext,
5264        fs: Arc<FakeFs>,
5265        root: &str,
5266    ) -> (Entity<NativeAgent>, Entity<Project>, WorktreeId) {
5267        use collections::{HashMap, HashSet};
5268        use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees};
5269
5270        cx.update(|cx| {
5271            trusted_worktrees::init(HashMap::default(), cx);
5272        });
5273
5274        let project = Project::test_with_worktree_trust(fs.clone(), [Path::new(root)], cx).await;
5275        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5276        let agent =
5277            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5278
5279        let connection = NativeAgentConnection(agent.clone());
5280        let _acp_thread = cx
5281            .update(|cx| {
5282                Rc::new(connection).new_session(
5283                    project.clone(),
5284                    PathList::new(&[Path::new(root)]),
5285                    cx,
5286                )
5287            })
5288            .await
5289            .unwrap();
5290        cx.run_until_parked();
5291
5292        let worktree_id = project.read_with(cx, |project, cx| {
5293            project.worktrees(cx).next().unwrap().read(cx).id()
5294        });
5295        cx.update(|cx| {
5296            let trusted_worktrees = TrustedWorktrees::try_get_global(cx)
5297                .expect("trusted worktrees global initialized by test_with_worktree_trust");
5298            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
5299                trusted_worktrees.trust(
5300                    &project.read(cx).worktree_store(),
5301                    HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
5302                    cx,
5303                );
5304            });
5305        });
5306        cx.run_until_parked();
5307
5308        (agent, project, worktree_id)
5309    }
5310
5311    /// The body resolver for a project-local skill must read the file
5312    /// through a project buffer rather than the local filesystem. This is
5313    /// what makes project skills resolvable in remote workspaces, where
5314    /// the `fs` the agent holds is the client's filesystem and not where
5315    /// the project files actually live. We prove the buffer path is used
5316    /// by editing the buffer in memory (without saving) and asserting the
5317    /// resolver returns the edited body, not the on-disk body.
5318    #[gpui::test]
5319    async fn test_project_skill_body_resolves_through_buffer(cx: &mut TestAppContext) {
5320        init_test(cx);
5321        let fs = FakeFs::new(cx.executor());
5322        fs.insert_tree(
5323            "/project",
5324            json!({
5325                ".agents": {
5326                    "skills": {
5327                        "my-skill": {
5328                            "SKILL.md": "---\nname: my-skill\ndescription: A project skill\n---\n\ndisk body"
5329                        }
5330                    }
5331                }
5332            }),
5333        )
5334        .await;
5335
5336        let (agent, project, worktree_id) =
5337            open_trusted_project_skills(cx, fs.clone(), "/project").await;
5338        let project_id = project.entity_id();
5339
5340        let skill = agent.read_with(cx, |agent, _cx| {
5341            let state = agent.projects.get(&project_id).unwrap();
5342            user_skills(&state.skills)
5343                .into_iter()
5344                .find(|s| s.name == "my-skill")
5345                .cloned()
5346                .expect("project skill should be loaded")
5347        });
5348        assert!(matches!(skill.source, SkillSource::ProjectLocal { .. }));
5349
5350        let resolver =
5351            cx.update(|_cx| super::skill_body_resolver_for_project(project.clone(), fs.clone()));
5352
5353        let body = cx
5354            .update(|cx| resolver(skill.clone(), &mut cx.to_async()))
5355            .await
5356            .unwrap();
5357        assert_eq!(body, "disk body");
5358
5359        // Edit the buffer in memory without writing to disk.
5360        let relative_path: Arc<RelPath> = rel_path(".agents/skills/my-skill/SKILL.md").into();
5361        let buffer = project
5362            .update(cx, |project, cx| {
5363                project.open_buffer((worktree_id, relative_path), cx)
5364            })
5365            .await
5366            .unwrap();
5367        buffer.update(cx, |buffer, cx| {
5368            buffer.set_text(
5369                "---\nname: my-skill\ndescription: A project skill\n---\n\nedited body",
5370                cx,
5371            );
5372        });
5373
5374        let body = cx
5375            .update(|cx| resolver(skill.clone(), &mut cx.to_async()))
5376            .await
5377            .unwrap();
5378        assert_eq!(
5379            body, "edited body",
5380            "resolver must read the in-memory buffer, not the on-disk file"
5381        );
5382    }
5383
5384    /// A project SKILL.md whose on-disk size exceeds the cap must be
5385    /// rejected with a size-limit error and excluded from the loaded
5386    /// skills, exercising the size guard in `load_project_skills`.
5387    #[gpui::test]
5388    async fn test_oversized_project_skill_reports_error(cx: &mut TestAppContext) {
5389        init_test(cx);
5390        let fs = FakeFs::new(cx.executor());
5391        let oversized = format!(
5392            "---\nname: huge-skill\ndescription: Too big\n---\n\n{}",
5393            "a".repeat(MAX_SKILL_FILE_SIZE + 1)
5394        );
5395        fs.insert_tree(
5396            "/project",
5397            json!({
5398                ".agents": { "skills": { "huge-skill": { "SKILL.md": oversized } } }
5399            }),
5400        )
5401        .await;
5402
5403        let (agent, project, _worktree_id) =
5404            open_trusted_project_skills(cx, fs.clone(), "/project").await;
5405        let project_id = project.entity_id();
5406
5407        agent.read_with(cx, |agent, _cx| {
5408            let state = agent.projects.get(&project_id).unwrap();
5409            assert!(
5410                user_skills(&state.skills).is_empty(),
5411                "oversized skill must not load: {:?}",
5412                user_skills(&state.skills)
5413                    .iter()
5414                    .map(|s| s.name.as_str())
5415                    .collect::<Vec<_>>()
5416            );
5417            assert!(
5418                state
5419                    .skill_loading_issues
5420                    .iter()
5421                    .any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed
5422                        && issue.message.to_string().contains("maximum size")),
5423                "expected a size-limit error, got {:?}",
5424                state.skill_loading_issues
5425            );
5426        });
5427    }
5428
5429    /// A malformed project SKILL.md must surface a per-skill load error
5430    /// without preventing sibling skills in the same worktree from
5431    /// loading.
5432    #[gpui::test]
5433    async fn test_malformed_project_skill_reports_error(cx: &mut TestAppContext) {
5434        init_test(cx);
5435        let fs = FakeFs::new(cx.executor());
5436        fs.insert_tree(
5437            "/project",
5438            json!({
5439                ".agents": {
5440                    "skills": {
5441                        "good": {
5442                            "SKILL.md": "---\nname: good\ndescription: Fine\n---\n\nbody"
5443                        },
5444                        "bad": {
5445                            "SKILL.md": "this file has no frontmatter"
5446                        }
5447                    }
5448                }
5449            }),
5450        )
5451        .await;
5452
5453        let (agent, project, _worktree_id) =
5454            open_trusted_project_skills(cx, fs.clone(), "/project").await;
5455        let project_id = project.entity_id();
5456
5457        agent.read_with(cx, |agent, _cx| {
5458            let state = agent.projects.get(&project_id).unwrap();
5459            let names: Vec<&str> = user_skills(&state.skills)
5460                .iter()
5461                .map(|s| s.name.as_str())
5462                .collect();
5463            assert_eq!(names, vec!["good"], "only the valid skill should load");
5464            assert!(
5465                state
5466                    .skill_loading_issues
5467                    .iter()
5468                    .any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed
5469                        && issue.path.ends_with("bad/SKILL.md")),
5470                "expected an error for the malformed skill, got {:?}",
5471                state.skill_loading_issues
5472            );
5473        });
5474    }
5475
5476    /// The skill catalog (metadata) is also loaded through project
5477    /// buffers, and the broadened `.agents` refresh trigger must rebuild
5478    /// it when files under `.agents` change. We edit the SKILL.md buffer
5479    /// in memory, then touch an unrelated file directly under `.agents`
5480    /// (not under `.agents/skills`) and assert the catalog reflects the
5481    /// in-memory edit. Under the previous `.agents/skills`-only trigger
5482    /// this refresh would not have fired.
5483    #[gpui::test]
5484    async fn test_project_skill_metadata_refreshes_from_buffer(cx: &mut TestAppContext) {
5485        init_test(cx);
5486        let fs = FakeFs::new(cx.executor());
5487        fs.insert_tree(
5488            "/project",
5489            json!({
5490                ".agents": {
5491                    "skills": {
5492                        "my-skill": {
5493                            "SKILL.md": "---\nname: my-skill\ndescription: Original\n---\n\nbody"
5494                        }
5495                    }
5496                }
5497            }),
5498        )
5499        .await;
5500
5501        let (agent, project, worktree_id) =
5502            open_trusted_project_skills(cx, fs.clone(), "/project").await;
5503        let project_id = project.entity_id();
5504
5505        agent.read_with(cx, |agent, _cx| {
5506            let state = agent.projects.get(&project_id).unwrap();
5507            let skill = user_skills(&state.skills)
5508                .into_iter()
5509                .find(|s| s.name == "my-skill")
5510                .expect("skill should be loaded");
5511            assert_eq!(skill.description, "Original");
5512        });
5513
5514        let relative_path: Arc<RelPath> = rel_path(".agents/skills/my-skill/SKILL.md").into();
5515        let buffer = project
5516            .update(cx, |project, cx| {
5517                project.open_buffer((worktree_id, relative_path), cx)
5518            })
5519            .await
5520            .unwrap();
5521        buffer.update(cx, |buffer, cx| {
5522            buffer.set_text(
5523                "---\nname: my-skill\ndescription: Edited in buffer\n---\n\nbody",
5524                cx,
5525            );
5526        });
5527
5528        // Touch a file directly under `.agents` (not under
5529        // `.agents/skills`) to trigger the broadened refresh path.
5530        fs.insert_file("/project/.agents/marker.txt", b"hello".to_vec())
5531            .await;
5532        cx.run_until_parked();
5533
5534        agent.read_with(cx, |agent, _cx| {
5535            let state = agent.projects.get(&project_id).unwrap();
5536            let skill = user_skills(&state.skills)
5537                .into_iter()
5538                .find(|s| s.name == "my-skill")
5539                .expect("skill should still be loaded");
5540            assert_eq!(
5541                skill.description, "Edited in buffer",
5542                "catalog must reflect the in-memory buffer after a refresh"
5543            );
5544        });
5545    }
5546
5547    #[gpui::test]
5548    async fn test_listing_models(cx: &mut TestAppContext) {
5549        init_test(cx);
5550        let fs = FakeFs::new(cx.executor());
5551        fs.insert_tree("/", json!({ "a": {}  })).await;
5552        let project = Project::test(fs.clone(), [], cx).await;
5553        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5554        let connection = NativeAgentConnection(
5555            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)),
5556        );
5557
5558        // Create a thread/session
5559        let acp_thread = cx
5560            .update(|cx| {
5561                Rc::new(connection.clone()).new_session(
5562                    project.clone(),
5563                    PathList::new(&[Path::new("/a")]),
5564                    cx,
5565                )
5566            })
5567            .await
5568            .unwrap();
5569
5570        let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
5571
5572        let models = cx
5573            .update(|cx| {
5574                connection
5575                    .model_selector(&session_id)
5576                    .unwrap()
5577                    .list_models(cx)
5578            })
5579            .await
5580            .unwrap();
5581
5582        let acp_thread::AgentModelList::Grouped(models) = models else {
5583            panic!("Unexpected model group");
5584        };
5585        assert_eq!(
5586            models,
5587            IndexMap::from_iter([(
5588                AgentModelGroupName("Fake".into()),
5589                vec![AgentModelInfo {
5590                    id: AgentModelId::new("fake/fake"),
5591                    name: "Fake".into(),
5592                    description: None,
5593                    icon: Some(acp_thread::AgentModelIcon::Named(
5594                        ui::IconName::OmegaAssistant
5595                    )),
5596                    is_latest: false,
5597                    disabled: None,
5598                    cost: None,
5599                }]
5600            )])
5601        );
5602    }
5603
5604    #[gpui::test]
5605    async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) {
5606        init_test(cx);
5607        let fs = FakeFs::new(cx.executor());
5608        fs.create_dir(paths::settings_file().parent().unwrap())
5609            .await
5610            .unwrap();
5611        fs.insert_file(
5612            paths::settings_file(),
5613            json!({
5614                "agent": {
5615                    "default_model": {
5616                        "provider": "foo",
5617                        "model": "bar"
5618                    }
5619                }
5620            })
5621            .to_string()
5622            .into_bytes(),
5623        )
5624        .await;
5625        let project = Project::test(fs.clone(), [], cx).await;
5626
5627        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5628
5629        // Create the agent and connection
5630        let agent =
5631            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5632        let connection = NativeAgentConnection(agent.clone());
5633
5634        // Create a thread/session
5635        let acp_thread = cx
5636            .update(|cx| {
5637                Rc::new(connection.clone()).new_session(
5638                    project.clone(),
5639                    PathList::new(&[Path::new("/a")]),
5640                    cx,
5641                )
5642            })
5643            .await
5644            .unwrap();
5645
5646        let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
5647
5648        // Select a model
5649        let selector = connection.model_selector(&session_id).unwrap();
5650        let model_id = AgentModelId::new("fake/fake");
5651        cx.update(|cx| selector.select_model(model_id.clone(), cx))
5652            .await
5653            .unwrap();
5654
5655        // Verify the thread has the selected model
5656        agent.read_with(cx, |agent, _| {
5657            let session = agent.sessions.get(&session_id).unwrap();
5658            session.thread.read_with(cx, |thread, _| {
5659                assert_eq!(thread.model().unwrap().id().0, "fake");
5660            });
5661        });
5662
5663        cx.run_until_parked();
5664
5665        // Verify settings file was updated
5666        let settings_content = fs.load(paths::settings_file()).await.unwrap();
5667        let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
5668
5669        // Check that the agent settings contain the selected model
5670        assert_eq!(
5671            settings_json["agent"]["default_model"]["model"],
5672            json!("fake")
5673        );
5674        assert_eq!(
5675            settings_json["agent"]["default_model"]["provider"],
5676            json!("fake")
5677        );
5678
5679        // Register a thinking model and select it.
5680        cx.update(|cx| {
5681            let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
5682                "fake-corp",
5683                "fake-thinking",
5684                "Fake Thinking",
5685                true,
5686            ));
5687            let thinking_provider = Arc::new(
5688                FakeLanguageModelProvider::new(
5689                    LanguageModelProviderId::from("fake-corp".to_string()),
5690                    LanguageModelProviderName::from("Fake Corp".to_string()),
5691                )
5692                .with_models(vec![thinking_model]),
5693            );
5694            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
5695                registry.register_provider(thinking_provider, cx);
5696            });
5697        });
5698        agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
5699
5700        let selector = connection.model_selector(&session_id).unwrap();
5701        cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
5702            .await
5703            .unwrap();
5704        cx.run_until_parked();
5705
5706        // Verify enable_thinking was written to settings as true.
5707        let settings_content = fs.load(paths::settings_file()).await.unwrap();
5708        let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
5709        assert_eq!(
5710            settings_json["agent"]["default_model"]["enable_thinking"],
5711            json!(true),
5712            "selecting a thinking model should persist enable_thinking: true to settings"
5713        );
5714    }
5715
5716    #[gpui::test]
5717    async fn test_select_model_updates_thinking_enabled(cx: &mut TestAppContext) {
5718        init_test(cx);
5719        let fs = FakeFs::new(cx.executor());
5720        fs.create_dir(paths::settings_file().parent().unwrap())
5721            .await
5722            .unwrap();
5723        fs.insert_file(paths::settings_file(), b"{}".to_vec()).await;
5724        let project = Project::test(fs.clone(), [], cx).await;
5725
5726        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5727        let agent =
5728            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5729        let connection = NativeAgentConnection(agent.clone());
5730
5731        let acp_thread = cx
5732            .update(|cx| {
5733                Rc::new(connection.clone()).new_session(
5734                    project.clone(),
5735                    PathList::new(&[Path::new("/a")]),
5736                    cx,
5737                )
5738            })
5739            .await
5740            .unwrap();
5741        let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
5742
5743        // Register a second provider with a thinking model.
5744        cx.update(|cx| {
5745            let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
5746                "fake-corp",
5747                "fake-thinking",
5748                "Fake Thinking",
5749                true,
5750            ));
5751            let thinking_provider = Arc::new(
5752                FakeLanguageModelProvider::new(
5753                    LanguageModelProviderId::from("fake-corp".to_string()),
5754                    LanguageModelProviderName::from("Fake Corp".to_string()),
5755                )
5756                .with_models(vec![thinking_model]),
5757            );
5758            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
5759                registry.register_provider(thinking_provider, cx);
5760            });
5761        });
5762        // Refresh the agent's model list so it picks up the new provider.
5763        agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
5764
5765        // Thread starts with thinking_enabled = false (the default).
5766        agent.read_with(cx, |agent, _| {
5767            let session = agent.sessions.get(&session_id).unwrap();
5768            session.thread.read_with(cx, |thread, _| {
5769                assert!(!thread.thinking_enabled(), "thinking defaults to false");
5770            });
5771        });
5772
5773        // Select the thinking model via select_model.
5774        let selector = connection.model_selector(&session_id).unwrap();
5775        cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
5776            .await
5777            .unwrap();
5778
5779        // select_model should have enabled thinking based on the model's supports_thinking().
5780        agent.read_with(cx, |agent, _| {
5781            let session = agent.sessions.get(&session_id).unwrap();
5782            session.thread.read_with(cx, |thread, _| {
5783                assert!(
5784                    thread.thinking_enabled(),
5785                    "select_model should enable thinking when model supports it"
5786                );
5787            });
5788        });
5789
5790        // Switch back to the non-thinking model.
5791        let selector = connection.model_selector(&session_id).unwrap();
5792        cx.update(|cx| selector.select_model(AgentModelId::new("fake/fake"), cx))
5793            .await
5794            .unwrap();
5795
5796        // select_model should have disabled thinking.
5797        agent.read_with(cx, |agent, _| {
5798            let session = agent.sessions.get(&session_id).unwrap();
5799            session.thread.read_with(cx, |thread, _| {
5800                assert!(
5801                    !thread.thinking_enabled(),
5802                    "select_model should disable thinking when model does not support it"
5803                );
5804            });
5805        });
5806    }
5807
5808    #[gpui::test]
5809    async fn test_summarization_model_survives_transient_registry_clearing(
5810        cx: &mut TestAppContext,
5811    ) {
5812        init_test(cx);
5813        let fs = FakeFs::new(cx.executor());
5814        fs.insert_tree("/", json!({ "a": {} })).await;
5815        let project = Project::test(fs.clone(), [], cx).await;
5816
5817        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5818        let agent =
5819            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
5820        let connection = Rc::new(NativeAgentConnection(agent.clone()));
5821
5822        let acp_thread = cx
5823            .update(|cx| {
5824                connection.clone().new_session(
5825                    project.clone(),
5826                    PathList::new(&[Path::new("/a")]),
5827                    cx,
5828                )
5829            })
5830            .await
5831            .unwrap();
5832        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5833
5834        let thread = agent.read_with(cx, |agent, _| {
5835            agent.sessions.get(&session_id).unwrap().thread.clone()
5836        });
5837
5838        thread.read_with(cx, |thread, _| {
5839            assert!(
5840                thread.summarization_model().is_some(),
5841                "session should have a summarization model from the test registry"
5842            );
5843        });
5844
5845        // Simulate what happens during a provider blip:
5846        // update_active_language_model_from_settings calls set_default_model(None)
5847        // when it can't resolve the model, clearing all fallbacks.
5848        cx.update(|cx| {
5849            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
5850                registry.set_default_model(None, cx);
5851            });
5852        });
5853        cx.run_until_parked();
5854
5855        thread.read_with(cx, |thread, _| {
5856            assert!(
5857                thread.summarization_model().is_some(),
5858                "summarization model should survive a transient default model clearing"
5859            );
5860        });
5861    }
5862
5863    #[gpui::test]
5864    async fn test_loaded_thread_preserves_thinking_enabled(cx: &mut TestAppContext) {
5865        init_test(cx);
5866        let fs = FakeFs::new(cx.executor());
5867        fs.insert_tree("/", json!({ "a": {} })).await;
5868        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5869        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5870        let agent = cx
5871            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5872        let connection = Rc::new(NativeAgentConnection(agent.clone()));
5873
5874        // Register a thinking model.
5875        let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
5876            "fake-corp",
5877            "fake-thinking",
5878            "Fake Thinking",
5879            true,
5880        ));
5881        let thinking_provider = Arc::new(
5882            FakeLanguageModelProvider::new(
5883                LanguageModelProviderId::from("fake-corp".to_string()),
5884                LanguageModelProviderName::from("Fake Corp".to_string()),
5885            )
5886            .with_models(vec![thinking_model.clone()]),
5887        );
5888        cx.update(|cx| {
5889            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
5890                registry.register_provider(thinking_provider, cx);
5891            });
5892        });
5893        agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
5894
5895        // Create a thread and select the thinking model.
5896        let acp_thread = cx
5897            .update(|cx| {
5898                connection.clone().new_session(
5899                    project.clone(),
5900                    PathList::new(&[Path::new("/a")]),
5901                    cx,
5902                )
5903            })
5904            .await
5905            .unwrap();
5906        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5907
5908        let selector = connection.model_selector(&session_id).unwrap();
5909        cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
5910            .await
5911            .unwrap();
5912
5913        // Verify thinking is enabled after selecting the thinking model.
5914        let thread = agent.read_with(cx, |agent, _| {
5915            agent.sessions.get(&session_id).unwrap().thread.clone()
5916        });
5917        thread.read_with(cx, |thread, _| {
5918            assert!(
5919                thread.thinking_enabled(),
5920                "thinking should be enabled after selecting thinking model"
5921            );
5922        });
5923
5924        // Send a message so the thread gets persisted.
5925        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
5926        let send = cx.foreground_executor().spawn(send);
5927        cx.run_until_parked();
5928
5929        thinking_model.send_last_completion_stream_text_chunk("Response.");
5930        thinking_model.end_last_completion_stream();
5931
5932        send.await.unwrap();
5933        cx.run_until_parked();
5934
5935        // Close the session so it can be reloaded from disk.
5936        cx.update(|cx| connection.clone().close_session(&session_id, cx))
5937            .await
5938            .unwrap();
5939        drop(thread);
5940        drop(acp_thread);
5941        agent.read_with(cx, |agent, _| {
5942            assert!(agent.sessions.is_empty());
5943        });
5944
5945        // Reload the thread and verify thinking_enabled is still true.
5946        let reloaded_acp_thread = agent
5947            .update(cx, |agent, cx| {
5948                agent.open_thread(session_id.clone(), project.clone(), cx)
5949            })
5950            .await
5951            .unwrap();
5952        let reloaded_thread = agent.read_with(cx, |agent, _| {
5953            agent.sessions.get(&session_id).unwrap().thread.clone()
5954        });
5955        reloaded_thread.read_with(cx, |thread, _| {
5956            assert!(
5957                thread.thinking_enabled(),
5958                "thinking_enabled should be preserved when reloading a thread with a thinking model"
5959            );
5960        });
5961
5962        drop(reloaded_acp_thread);
5963    }
5964
5965    #[gpui::test]
5966    async fn test_loaded_thread_preserves_model(cx: &mut TestAppContext) {
5967        init_test(cx);
5968        let fs = FakeFs::new(cx.executor());
5969        fs.insert_tree("/", json!({ "a": {} })).await;
5970        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5971        let thread_store = cx.new(|cx| ThreadStore::new(cx));
5972        let agent = cx
5973            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5974        let connection = Rc::new(NativeAgentConnection(agent.clone()));
5975
5976        // Register a model where id() != name(), like real Anthropic models
5977        // (e.g. id="claude-sonnet-4-5-thinking-latest", name="Claude Sonnet 4.5 Thinking").
5978        let model = Arc::new(FakeLanguageModel::with_id_and_thinking(
5979            "fake-corp",
5980            "custom-model-id",
5981            "Custom Model Display Name",
5982            false,
5983        ));
5984        let provider = Arc::new(
5985            FakeLanguageModelProvider::new(
5986                LanguageModelProviderId::from("fake-corp".to_string()),
5987                LanguageModelProviderName::from("Fake Corp".to_string()),
5988            )
5989            .with_models(vec![model.clone()]),
5990        );
5991        cx.update(|cx| {
5992            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
5993                registry.register_provider(provider, cx);
5994            });
5995        });
5996        agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
5997
5998        // Create a thread and select the model.
5999        let acp_thread = cx
6000            .update(|cx| {
6001                connection.clone().new_session(
6002                    project.clone(),
6003                    PathList::new(&[Path::new("/a")]),
6004                    cx,
6005                )
6006            })
6007            .await
6008            .unwrap();
6009        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6010
6011        let selector = connection.model_selector(&session_id).unwrap();
6012        cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx))
6013            .await
6014            .unwrap();
6015
6016        let thread = agent.read_with(cx, |agent, _| {
6017            agent.sessions.get(&session_id).unwrap().thread.clone()
6018        });
6019        thread.read_with(cx, |thread, _| {
6020            assert_eq!(
6021                thread.model().unwrap().id().0.as_ref(),
6022                "custom-model-id",
6023                "model should be set before persisting"
6024            );
6025        });
6026
6027        // Send a message so the thread gets persisted.
6028        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
6029        let send = cx.foreground_executor().spawn(send);
6030        cx.run_until_parked();
6031
6032        model.send_last_completion_stream_text_chunk("Response.");
6033        model.end_last_completion_stream();
6034
6035        send.await.unwrap();
6036        cx.run_until_parked();
6037
6038        // Close the session so it can be reloaded from disk.
6039        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6040            .await
6041            .unwrap();
6042        drop(thread);
6043        drop(acp_thread);
6044        agent.read_with(cx, |agent, _| {
6045            assert!(agent.sessions.is_empty());
6046        });
6047
6048        // Reload the thread and verify the model was preserved.
6049        let reloaded_acp_thread = agent
6050            .update(cx, |agent, cx| {
6051                agent.open_thread(session_id.clone(), project.clone(), cx)
6052            })
6053            .await
6054            .unwrap();
6055        let reloaded_thread = agent.read_with(cx, |agent, _| {
6056            agent.sessions.get(&session_id).unwrap().thread.clone()
6057        });
6058        reloaded_thread.read_with(cx, |thread, _| {
6059            let reloaded_model = thread
6060                .model()
6061                .expect("model should be present after reload");
6062            assert_eq!(
6063                reloaded_model.id().0.as_ref(),
6064                "custom-model-id",
6065                "reloaded thread should have the same model, not fall back to the default"
6066            );
6067        });
6068
6069        drop(reloaded_acp_thread);
6070    }
6071
6072    async fn persist_thread_with_fake_corp_model(
6073        cx: &mut TestAppContext,
6074    ) -> (
6075        Entity<NativeAgent>,
6076        Rc<NativeAgentConnection>,
6077        Entity<Project>,
6078        acp::SessionId,
6079        Arc<FakeLanguageModelProvider>,
6080    ) {
6081        let fs = FakeFs::new(cx.executor());
6082        fs.insert_tree("/", json!({ "a": {} })).await;
6083        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6084        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6085        let agent = cx
6086            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6087        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6088
6089        let model = Arc::new(FakeLanguageModel::with_id_and_thinking(
6090            "fake-corp",
6091            "custom-model-id",
6092            "Custom Model Display Name",
6093            false,
6094        ));
6095        let provider = Arc::new(
6096            FakeLanguageModelProvider::new(
6097                LanguageModelProviderId::from("fake-corp".to_string()),
6098                LanguageModelProviderName::from("Fake Corp".to_string()),
6099            )
6100            .with_models(vec![model.clone()]),
6101        );
6102        cx.update(|cx| {
6103            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6104                registry.register_provider(provider.clone(), cx);
6105            });
6106        });
6107        agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
6108
6109        let acp_thread = cx
6110            .update(|cx| {
6111                connection.clone().new_session(
6112                    project.clone(),
6113                    PathList::new(&[Path::new("/a")]),
6114                    cx,
6115                )
6116            })
6117            .await
6118            .unwrap();
6119        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6120
6121        let selector = connection.model_selector(&session_id).unwrap();
6122        cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx))
6123            .await
6124            .unwrap();
6125
6126        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
6127        let send = cx.foreground_executor().spawn(send);
6128        cx.run_until_parked();
6129        model.send_last_completion_stream_text_chunk("Response.");
6130        model.end_last_completion_stream();
6131        send.await.unwrap();
6132        cx.run_until_parked();
6133
6134        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6135            .await
6136            .unwrap();
6137        drop(acp_thread);
6138
6139        (agent, connection, project, session_id, provider)
6140    }
6141
6142    fn unregister_fake_corp(cx: &mut TestAppContext) {
6143        cx.update(|cx| {
6144            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6145                registry.unregister_provider(
6146                    LanguageModelProviderId::from("fake-corp".to_string()),
6147                    cx,
6148                );
6149            });
6150        });
6151    }
6152
6153    #[gpui::test]
6154    async fn test_loaded_thread_resolves_model_when_provider_loads_late(cx: &mut TestAppContext) {
6155        init_test(cx);
6156        let (agent, _connection, project, session_id, provider) =
6157            persist_thread_with_fake_corp_model(cx).await;
6158
6159        // Simulate a restart where the provider hasn't fetched its model list
6160        // yet, so the saved selection can't be resolved at load time.
6161        unregister_fake_corp(cx);
6162
6163        let reloaded_acp_thread = agent
6164            .update(cx, |agent, cx| {
6165                agent.open_thread(session_id.clone(), project.clone(), cx)
6166            })
6167            .await
6168            .unwrap();
6169        let thread = agent.read_with(cx, |agent, _| {
6170            agent.sessions.get(&session_id).unwrap().thread.clone()
6171        });
6172        thread.read_with(cx, |thread, _| {
6173            assert!(
6174                thread.model().is_none(),
6175                "should not fall back to an unrelated model"
6176            );
6177        });
6178
6179        // The original selection is persisted even while unresolved, so a save
6180        // during the window can't overwrite the user's choice with a fallback.
6181        let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await;
6182        let saved = db_thread.model.expect("selection should be persisted");
6183        assert_eq!(saved.provider, "fake-corp");
6184        assert_eq!(saved.model, "custom-model-id");
6185
6186        cx.update(|cx| {
6187            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6188                registry.register_provider(provider.clone(), cx);
6189            });
6190        });
6191        cx.run_until_parked();
6192
6193        thread.read_with(cx, |thread, _| {
6194            assert_eq!(
6195                thread
6196                    .model()
6197                    .expect("model should resolve once provider loads")
6198                    .id()
6199                    .0
6200                    .as_ref(),
6201                "custom-model-id"
6202            );
6203        });
6204
6205        drop(reloaded_acp_thread);
6206    }
6207
6208    #[gpui::test]
6209    async fn test_explicit_model_selection_cancels_pending(cx: &mut TestAppContext) {
6210        init_test(cx);
6211        let (agent, connection, project, session_id, provider) =
6212            persist_thread_with_fake_corp_model(cx).await;
6213
6214        unregister_fake_corp(cx);
6215
6216        let reloaded_acp_thread = agent
6217            .update(cx, |agent, cx| {
6218                agent.open_thread(session_id.clone(), project.clone(), cx)
6219            })
6220            .await
6221            .unwrap();
6222        let thread = agent.read_with(cx, |agent, _| {
6223            agent.sessions.get(&session_id).unwrap().thread.clone()
6224        });
6225        thread.read_with(cx, |thread, _| {
6226            assert!(thread.model().is_none());
6227        });
6228
6229        // The user explicitly picks a different, available model.
6230        let other_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
6231            "other-corp",
6232            "other-model-id",
6233            "Other Model",
6234            false,
6235        ));
6236        let other_provider = Arc::new(
6237            FakeLanguageModelProvider::new(
6238                LanguageModelProviderId::from("other-corp".to_string()),
6239                LanguageModelProviderName::from("Other Corp".to_string()),
6240            )
6241            .with_models(vec![other_model.clone()]),
6242        );
6243        cx.update(|cx| {
6244            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6245                registry.register_provider(other_provider, cx);
6246            });
6247        });
6248        cx.run_until_parked();
6249
6250        let selector = connection.model_selector(&session_id).unwrap();
6251        cx.update(|cx| selector.select_model(AgentModelId::new("other-corp/other-model-id"), cx))
6252            .await
6253            .unwrap();
6254
6255        thread.read_with(cx, |thread, _| {
6256            assert_eq!(thread.model().unwrap().id().0.as_ref(), "other-model-id");
6257        });
6258
6259        // The original provider returning must not clobber the explicit choice.
6260        cx.update(|cx| {
6261            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6262                registry.register_provider(provider.clone(), cx);
6263            });
6264        });
6265        cx.run_until_parked();
6266
6267        thread.read_with(cx, |thread, _| {
6268            assert_eq!(
6269                thread.model().unwrap().id().0.as_ref(),
6270                "other-model-id",
6271                "a late provider load must not override the explicit selection"
6272            );
6273        });
6274
6275        drop(reloaded_acp_thread);
6276    }
6277
6278    #[gpui::test]
6279    async fn test_save_load_thread(cx: &mut TestAppContext) {
6280        init_test(cx);
6281        let fs = FakeFs::new(cx.executor());
6282        fs.insert_tree(
6283            "/",
6284            json!({
6285                "a": {
6286                    "b.md": "Lorem"
6287                }
6288            }),
6289        )
6290        .await;
6291        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6292        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6293        let agent = cx
6294            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6295        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6296
6297        let acp_thread = cx
6298            .update(|cx| {
6299                connection
6300                    .clone()
6301                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6302            })
6303            .await
6304            .unwrap();
6305        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6306        let thread = agent.read_with(cx, |agent, _| {
6307            agent.sessions.get(&session_id).unwrap().thread.clone()
6308        });
6309
6310        // Ensure empty threads are not saved, even if they get mutated.
6311        let model = Arc::new(FakeLanguageModel::default());
6312        let summary_model = Arc::new(FakeLanguageModel::default());
6313        thread.update(cx, |thread, cx| {
6314            thread.set_model(model.clone(), cx);
6315            thread.set_summarization_model(Some(summary_model.clone()), cx);
6316        });
6317        cx.run_until_parked();
6318        assert_eq!(thread_entries(&thread_store, cx), vec![]);
6319
6320        let send = acp_thread.update(cx, |thread, cx| {
6321            thread.send(
6322                vec![
6323                    "What does ".into(),
6324                    acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
6325                        "b.md",
6326                        MentionUri::File {
6327                            abs_path: path!("/a/b.md").into(),
6328                        }
6329                        .to_uri()
6330                        .to_string(),
6331                    )),
6332                    " mean?".into(),
6333                ],
6334                cx,
6335            )
6336        });
6337        let send = cx.foreground_executor().spawn(send);
6338        cx.run_until_parked();
6339
6340        model.send_last_completion_stream_text_chunk("Lorem.");
6341        model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
6342            language_model::TokenUsage {
6343                input_tokens: 150,
6344                output_tokens: 75,
6345                ..Default::default()
6346            },
6347        ));
6348        model.end_last_completion_stream();
6349        cx.run_until_parked();
6350        summary_model
6351            .send_last_completion_stream_text_chunk(&format!("Explaining {}", path!("/a/b.md")));
6352        summary_model.end_last_completion_stream();
6353
6354        send.await.unwrap();
6355        let uri = MentionUri::File {
6356            abs_path: path!("/a/b.md").into(),
6357        }
6358        .to_uri();
6359        acp_thread.read_with(cx, |thread, cx| {
6360            assert_eq!(
6361                thread.to_markdown(cx),
6362                formatdoc! {"
6363                    ## User
6364
6365                    What does [@b.md]({uri}) mean?
6366
6367                    ## Assistant
6368
6369                    Lorem.
6370
6371                "}
6372            )
6373        });
6374
6375        cx.run_until_parked();
6376
6377        // Set a draft prompt with rich content blocks and scroll position
6378        // AFTER run_until_parked, so the only save that captures these
6379        // changes is the one performed by close_session itself.
6380        let draft_blocks = vec![
6381            acp::ContentBlock::Text(acp::TextContent::new("Check out ")),
6382            acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())),
6383            acp::ContentBlock::Text(acp::TextContent::new(" please")),
6384        ];
6385        acp_thread.update(cx, |thread, cx| {
6386            thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
6387        });
6388        thread.update(cx, |thread, _cx| {
6389            thread.set_ui_scroll_position(Some(gpui::ListOffset {
6390                item_ix: 5,
6391                offset_in_item: gpui::px(12.5),
6392            }));
6393        });
6394
6395        // Close the session so it can be reloaded from disk.
6396        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6397            .await
6398            .unwrap();
6399        drop(thread);
6400        drop(acp_thread);
6401        agent.read_with(cx, |agent, _| {
6402            assert_eq!(agent.sessions.keys().cloned().collect::<Vec<_>>(), []);
6403        });
6404
6405        // Ensure the thread can be reloaded from disk.
6406        assert_eq!(
6407            thread_entries(&thread_store, cx),
6408            vec![(
6409                session_id.clone(),
6410                format!("Explaining {}", path!("/a/b.md"))
6411            )]
6412        );
6413        let acp_thread = agent
6414            .update(cx, |agent, cx| {
6415                agent.open_thread(session_id.clone(), project.clone(), cx)
6416            })
6417            .await
6418            .unwrap();
6419        acp_thread.read_with(cx, |thread, cx| {
6420            assert_eq!(
6421                thread.to_markdown(cx),
6422                formatdoc! {"
6423                    ## User
6424
6425                    What does [@b.md]({uri}) mean?
6426
6427                    ## Assistant
6428
6429                    Lorem.
6430
6431                "}
6432            )
6433        });
6434
6435        // Ensure the draft prompt with rich content blocks survived the round-trip.
6436        acp_thread.read_with(cx, |thread, _| {
6437            assert_eq!(thread.draft_prompt(), Some(draft_blocks.as_slice()));
6438        });
6439
6440        // Ensure token usage survived the round-trip.
6441        acp_thread.read_with(cx, |thread, _| {
6442            let usage = thread
6443                .token_usage()
6444                .expect("token usage should be restored after reload");
6445            assert_eq!(usage.input_tokens, 150);
6446            assert_eq!(usage.output_tokens, 75);
6447        });
6448
6449        // Ensure scroll position survived the round-trip.
6450        acp_thread.read_with(cx, |thread, _| {
6451            let scroll = thread
6452                .ui_scroll_position()
6453                .expect("scroll position should be restored after reload");
6454            assert_eq!(scroll.item_ix, 5);
6455            assert_eq!(scroll.offset_in_item, gpui::px(12.5));
6456        });
6457    }
6458
6459    #[gpui::test]
6460    async fn test_close_session_saves_thread(cx: &mut TestAppContext) {
6461        init_test(cx);
6462        let fs = FakeFs::new(cx.executor());
6463        fs.insert_tree(
6464            "/",
6465            json!({
6466                "a": {
6467                    "file.txt": "hello"
6468                }
6469            }),
6470        )
6471        .await;
6472        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6473        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6474        let agent = cx
6475            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6476        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6477
6478        let acp_thread = cx
6479            .update(|cx| {
6480                connection
6481                    .clone()
6482                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6483            })
6484            .await
6485            .unwrap();
6486        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6487        let thread = agent.read_with(cx, |agent, _| {
6488            agent.sessions.get(&session_id).unwrap().thread.clone()
6489        });
6490
6491        let model = Arc::new(FakeLanguageModel::default());
6492        thread.update(cx, |thread, cx| {
6493            thread.set_model(model.clone(), cx);
6494        });
6495
6496        // Send a message so the thread is non-empty (empty threads aren't saved).
6497        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
6498        let send = cx.foreground_executor().spawn(send);
6499        cx.run_until_parked();
6500
6501        model.send_last_completion_stream_text_chunk("world");
6502        model.end_last_completion_stream();
6503        send.await.unwrap();
6504        cx.run_until_parked();
6505
6506        // Set a draft prompt WITHOUT calling run_until_parked afterwards.
6507        // This means no observe-triggered save has run for this change.
6508        // The only way this data gets persisted is if close_session
6509        // itself performs the save.
6510        let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
6511            "unsaved draft",
6512        ))];
6513        acp_thread.update(cx, |thread, cx| {
6514            thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
6515        });
6516
6517        // Close the session immediately — no run_until_parked in between.
6518        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6519            .await
6520            .unwrap();
6521        cx.run_until_parked();
6522
6523        // Reopen and verify the draft prompt was saved.
6524        let reloaded = agent
6525            .update(cx, |agent, cx| {
6526                agent.open_thread(session_id.clone(), project.clone(), cx)
6527            })
6528            .await
6529            .unwrap();
6530        reloaded.read_with(cx, |thread, _| {
6531            assert_eq!(
6532                thread.draft_prompt(),
6533                Some(draft_blocks.as_slice()),
6534                "close_session must save the thread; draft prompt was lost"
6535            );
6536        });
6537    }
6538
6539    #[gpui::test]
6540    async fn test_thread_summary_releases_loaded_session(cx: &mut TestAppContext) {
6541        init_test(cx);
6542        let fs = FakeFs::new(cx.executor());
6543        fs.insert_tree(
6544            "/",
6545            json!({
6546                "a": {
6547                    "file.txt": "hello"
6548                }
6549            }),
6550        )
6551        .await;
6552        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6553        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6554        let agent = cx
6555            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6556        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6557
6558        let acp_thread = cx
6559            .update(|cx| {
6560                connection
6561                    .clone()
6562                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6563            })
6564            .await
6565            .unwrap();
6566        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6567        let thread = agent.read_with(cx, |agent, _| {
6568            agent.sessions.get(&session_id).unwrap().thread.clone()
6569        });
6570
6571        let model = Arc::new(FakeLanguageModel::default());
6572        let summary_model = Arc::new(FakeLanguageModel::default());
6573        thread.update(cx, |thread, cx| {
6574            thread.set_model(model.clone(), cx);
6575            thread.set_summarization_model(Some(summary_model.clone()), cx);
6576        });
6577
6578        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
6579        let send = cx.foreground_executor().spawn(send);
6580        cx.run_until_parked();
6581
6582        model.send_last_completion_stream_text_chunk("world");
6583        model.end_last_completion_stream();
6584        send.await.unwrap();
6585        cx.run_until_parked();
6586
6587        let summary = agent.update(cx, |agent, cx| {
6588            agent.thread_summary(session_id.clone(), project.clone(), cx)
6589        });
6590        cx.run_until_parked();
6591
6592        summary_model.send_last_completion_stream_text_chunk("summary");
6593        summary_model.end_last_completion_stream();
6594
6595        assert_eq!(summary.await.unwrap(), "summary");
6596        cx.run_until_parked();
6597
6598        agent.read_with(cx, |agent, _| {
6599            let session = agent
6600                .sessions
6601                .get(&session_id)
6602                .expect("thread_summary should not close the active session");
6603            assert_eq!(
6604                session.ref_count, 1,
6605                "thread_summary should release its temporary session reference"
6606            );
6607        });
6608
6609        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6610            .await
6611            .unwrap();
6612        cx.run_until_parked();
6613
6614        agent.read_with(cx, |agent, _| {
6615            assert!(
6616                agent.sessions.is_empty(),
6617                "closing the active session after thread_summary should unload it"
6618            );
6619        });
6620    }
6621
6622    #[gpui::test]
6623    async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut TestAppContext) {
6624        init_test(cx);
6625        let fs = FakeFs::new(cx.executor());
6626        fs.insert_tree(
6627            "/",
6628            json!({
6629                "a": {
6630                    "file.txt": "hello"
6631                }
6632            }),
6633        )
6634        .await;
6635        let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6636        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6637        let agent = cx
6638            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6639        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6640
6641        let acp_thread = cx
6642            .update(|cx| {
6643                connection
6644                    .clone()
6645                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6646            })
6647            .await
6648            .unwrap();
6649        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6650        let thread = agent.read_with(cx, |agent, _| {
6651            agent.sessions.get(&session_id).unwrap().thread.clone()
6652        });
6653
6654        let model = cx.update(|cx| {
6655            LanguageModelRegistry::read_global(cx)
6656                .default_model()
6657                .map(|default_model| default_model.model)
6658                .expect("default test model should be available")
6659        });
6660        let fake_model = model.as_fake();
6661        thread.update(cx, |thread, cx| {
6662            thread.set_model(model.clone(), cx);
6663        });
6664
6665        let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
6666        let send = cx.foreground_executor().spawn(send);
6667        cx.run_until_parked();
6668
6669        fake_model.send_last_completion_stream_text_chunk("world");
6670        fake_model.end_last_completion_stream();
6671        send.await.unwrap();
6672        cx.run_until_parked();
6673
6674        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6675            .await
6676            .unwrap();
6677        drop(thread);
6678        drop(acp_thread);
6679        agent.read_with(cx, |agent, _| {
6680            assert!(agent.sessions.is_empty());
6681        });
6682
6683        let first_loaded_thread = cx.update(|cx| {
6684            connection.clone().load_session(
6685                session_id.clone(),
6686                project.clone(),
6687                PathList::new(&[Path::new("")]),
6688                None,
6689                cx,
6690            )
6691        });
6692        let second_loaded_thread = cx.update(|cx| {
6693            connection.clone().load_session(
6694                session_id.clone(),
6695                project.clone(),
6696                PathList::new(&[Path::new("")]),
6697                None,
6698                cx,
6699            )
6700        });
6701
6702        let first_loaded_thread = first_loaded_thread.await.unwrap();
6703        let second_loaded_thread = second_loaded_thread.await.unwrap();
6704
6705        cx.run_until_parked();
6706
6707        assert_eq!(
6708            first_loaded_thread.entity_id(),
6709            second_loaded_thread.entity_id(),
6710            "concurrent loads for the same session should share one AcpThread"
6711        );
6712
6713        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6714            .await
6715            .unwrap();
6716
6717        agent.read_with(cx, |agent, _| {
6718            assert!(
6719                agent.sessions.contains_key(&session_id),
6720                "closing one loaded session should not drop shared session state"
6721            );
6722        });
6723
6724        let follow_up = second_loaded_thread.update(cx, |thread, cx| {
6725            thread.send(vec!["still there?".into()], cx)
6726        });
6727        let follow_up = cx.foreground_executor().spawn(follow_up);
6728        cx.run_until_parked();
6729
6730        fake_model.send_last_completion_stream_text_chunk("yes");
6731        fake_model.end_last_completion_stream();
6732        follow_up.await.unwrap();
6733        cx.run_until_parked();
6734
6735        second_loaded_thread.read_with(cx, |thread, cx| {
6736            assert_eq!(
6737                thread.to_markdown(cx),
6738                formatdoc! {"
6739                    ## User
6740
6741                    hello
6742
6743                    ## Assistant
6744
6745                    world
6746
6747                    ## User
6748
6749                    still there?
6750
6751                    ## Assistant
6752
6753                    yes
6754
6755                "}
6756            );
6757        });
6758
6759        cx.update(|cx| connection.clone().close_session(&session_id, cx))
6760            .await
6761            .unwrap();
6762
6763        cx.run_until_parked();
6764
6765        drop(first_loaded_thread);
6766        drop(second_loaded_thread);
6767        agent.read_with(cx, |agent, _| {
6768            assert!(agent.sessions.is_empty());
6769        });
6770    }
6771
6772    #[gpui::test]
6773    async fn test_rapid_title_changes_do_not_loop(cx: &mut TestAppContext) {
6774        // Regression test: rapid title changes must not cause a propagation loop
6775        // between Thread and AcpThread via handle_thread_title_updated.
6776        init_test(cx);
6777        let fs = FakeFs::new(cx.executor());
6778        fs.insert_tree("/", json!({ "a": {} })).await;
6779        let project = Project::test(fs.clone(), [], cx).await;
6780        let thread_store = cx.new(|cx| ThreadStore::new(cx));
6781        let agent = cx
6782            .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6783        let connection = Rc::new(NativeAgentConnection(agent.clone()));
6784
6785        let acp_thread = cx
6786            .update(|cx| {
6787                connection
6788                    .clone()
6789                    .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6790            })
6791            .await
6792            .unwrap();
6793
6794        let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6795        let thread = agent.read_with(cx, |agent, _| {
6796            agent.sessions.get(&session_id).unwrap().thread.clone()
6797        });
6798
6799        let title_updated_count = Rc::new(std::cell::RefCell::new(0usize));
6800        cx.update(|cx| {
6801            let count = title_updated_count.clone();
6802            cx.subscribe(
6803                &thread,
6804                move |_entity: Entity<Thread>, _event: &TitleUpdated, _cx: &mut App| {
6805                    let new_count = {
6806                        let mut count = count.borrow_mut();
6807                        *count += 1;
6808                        *count
6809                    };
6810                    assert!(
6811                        new_count <= 2,
6812                        "TitleUpdated fired {new_count} times; \
6813                         title updates are looping"
6814                    );
6815                },
6816            )
6817            .detach();
6818        });
6819
6820        thread.update(cx, |thread, cx| thread.set_title("first".into(), cx));
6821        thread.update(cx, |thread, cx| thread.set_title("second".into(), cx));
6822
6823        cx.run_until_parked();
6824
6825        thread.read_with(cx, |thread, _| {
6826            assert_eq!(thread.title(), Some("second".into()));
6827        });
6828        acp_thread.read_with(cx, |acp_thread, _| {
6829            assert_eq!(acp_thread.title(), Some("second".into()));
6830        });
6831
6832        assert_eq!(*title_updated_count.borrow(), 2);
6833    }
6834
6835    fn thread_entries(
6836        thread_store: &Entity<ThreadStore>,
6837        cx: &mut TestAppContext,
6838    ) -> Vec<(acp::SessionId, String)> {
6839        thread_store.read_with(cx, |store, _| {
6840            store
6841                .entries()
6842                .map(|entry| (entry.id.clone(), entry.title.to_string()))
6843                .collect::<Vec<_>>()
6844        })
6845    }
6846
6847    fn init_test(cx: &mut TestAppContext) {
6848        env_logger::try_init().ok();
6849        cx.update(|cx| {
6850            let settings_store = SettingsStore::test(cx);
6851            cx.set_global(settings_store);
6852
6853            LanguageModelRegistry::test(cx);
6854        });
6855    }
6856
6857    #[test]
6858    fn test_strip_slash_command_prefix_keeps_inline_args() {
6859        // The bug being guarded against: skill slash invocation used to
6860        // discard the entire first text block, which threw away anything
6861        // the user typed on the same line as the command.
6862        assert_eq!(
6863            strip_slash_command_prefix("/fix-review #1, #2, #3"),
6864            "#1, #2, #3",
6865        );
6866    }
6867
6868    #[test]
6869    fn test_strip_slash_command_prefix_preserves_newlines() {
6870        // Continuations across newlines are common when users compose
6871        // structured prompts; the first newline is the command terminator,
6872        // but everything after it must reach the model verbatim.
6873        assert_eq!(
6874            strip_slash_command_prefix("/fix-review\nline 1\nline 2"),
6875            "line 1\nline 2",
6876        );
6877    }
6878
6879    #[test]
6880    fn test_strip_slash_command_prefix_command_only_is_empty() {
6881        assert_eq!(strip_slash_command_prefix("/fix-review"), "");
6882        assert_eq!(strip_slash_command_prefix("/fix-review "), "");
6883    }
6884
6885    #[test]
6886    fn test_strip_slash_command_prefix_ignores_leading_whitespace() {
6887        assert_eq!(strip_slash_command_prefix("   /fix-review hello"), "hello",);
6888    }
6889
6890    #[test]
6891    fn test_strip_slash_command_prefix_passes_through_non_command_text() {
6892        // Defense in depth: if somehow we're called with a non-slash-prefixed
6893        // block, the safe behavior is to return it unchanged rather than
6894        // silently mangling unrelated user text.
6895        assert_eq!(strip_slash_command_prefix("hello world"), "hello world",);
6896    }
6897}
6898
6899fn mcp_message_content_to_acp_content_block(
6900    content: context_server::types::MessageContent,
6901) -> acp::ContentBlock {
6902    match content {
6903        context_server::types::MessageContent::Text {
6904            text,
6905            annotations: _,
6906        } => text.into(),
6907        context_server::types::MessageContent::Image {
6908            data,
6909            mime_type,
6910            annotations: _,
6911        } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
6912        context_server::types::MessageContent::Audio {
6913            data,
6914            mime_type,
6915            annotations: _,
6916        } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
6917        context_server::types::MessageContent::Resource {
6918            resource,
6919            annotations: _,
6920        } => {
6921            let mut link =
6922                acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
6923            if let Some(mime_type) = resource.mime_type {
6924                link = link.mime_type(mime_type);
6925            }
6926            acp::ContentBlock::ResourceLink(link)
6927        }
6928    }
6929}
6930
Served at tenant.openagents/omega Member data and write actions are omitted.