Skip to repository content6930 lines · 266.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:32:53.137Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
agent.rs
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(¶ms.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