mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-19 11:04:06 +00:00
Publish native slash commands directly as ACP v2 values through the existing shared command updater, removing the internal v1 notification/conversion round trip. Release Notes: - N/A
8412 lines
320 KiB
Rust
8412 lines
320 KiB
Rust
mod db;
|
|
mod legacy_thread;
|
|
mod native_agent_server;
|
|
pub mod outline;
|
|
mod pattern_extraction;
|
|
mod sandboxing;
|
|
mod templates;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
mod thread;
|
|
mod thread_store;
|
|
mod tool_permissions;
|
|
mod tools;
|
|
|
|
use context_server::ContextServerId;
|
|
pub use db::*;
|
|
use itertools::Itertools;
|
|
pub use native_agent_server::NativeAgentServer;
|
|
use parking_lot::Mutex;
|
|
pub use pattern_extraction::*;
|
|
pub use sandboxing::{
|
|
ThreadSandbox, sandbox_worktree_writable_paths, settings_sandbox_policy,
|
|
settings_thread_sandbox,
|
|
};
|
|
pub use shell_command_parser::extract_commands;
|
|
pub use templates::*;
|
|
pub use thread::*;
|
|
pub use thread_store::*;
|
|
pub use tool_permissions::*;
|
|
pub use tools::*;
|
|
|
|
use acp_thread::{
|
|
AcpThread, AgentModelId, AgentModelSelector, AgentSessionInfo, AgentSessionList,
|
|
AgentSessionListRequest, AgentSessionListResponse, ClientUserMessageId, TokenUsageRatio,
|
|
};
|
|
use agent_client_protocol::schema::v1 as acp_v1;
|
|
use agent_client_protocol::schema::v2 as acp_v2;
|
|
use agent_skills::{
|
|
AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTIONS_SIZE, MAX_SKILL_FILE_SIZE, ProjectSkillGroup,
|
|
SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillLoadWarning, SkillScopeId,
|
|
SkillSource, SkillSummary, builtin_skills, global_skills_dir, load_skills_from_directory,
|
|
parse_skill_frontmatter, project_skills_relative_path, read_skill_body_from_content,
|
|
};
|
|
use anyhow::{Context as _, Result, anyhow};
|
|
use chrono::{DateTime, Utc};
|
|
use collections::{HashMap, HashSet, IndexMap};
|
|
|
|
use fs::Fs;
|
|
use futures::channel::{mpsc, oneshot};
|
|
use futures::future::Shared;
|
|
use futures::{FutureExt as _, StreamExt as _, future};
|
|
use gpui::{
|
|
App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
|
|
TaskExt, WeakEntity,
|
|
};
|
|
use language_model::{
|
|
IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId,
|
|
LanguageModelRegistry,
|
|
};
|
|
use project::{
|
|
AgentId, Project, ProjectItem, ProjectPath, Worktree, WorktreeId,
|
|
trusted_worktrees::TrustedWorktrees,
|
|
};
|
|
use prompt_store::{ProjectContext, RULES_FILE_NAMES, RulesFileContext, WorktreeContext};
|
|
use rand::Rng as _;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{LanguageModelSelection, Settings as _, update_settings_file};
|
|
use std::any::Any;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
use std::sync::{Arc, LazyLock};
|
|
use std::time::Duration;
|
|
use util::ResultExt;
|
|
use util::path_list::PathList;
|
|
use util::rel_path::RelPath;
|
|
|
|
const MAXIMUM_RETRY_JITTER_FRACTION: f64 = 0.1;
|
|
|
|
pub(crate) fn jitter_retry_delay(delay: Duration) -> Duration {
|
|
let jitter = delay.mul_f64(rand::rng().random_range(0.0..MAXIMUM_RETRY_JITTER_FRACTION));
|
|
delay.checked_add(jitter).unwrap_or(Duration::MAX)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn maximum_retry_delay_with_jitter(delay: Duration) -> Duration {
|
|
let maximum_jitter = delay.mul_f64(MAXIMUM_RETRY_JITTER_FRACTION);
|
|
delay.checked_add(maximum_jitter).unwrap_or(Duration::MAX)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct ProjectSnapshot {
|
|
pub worktree_snapshots: Vec<project::telemetry_snapshot::TelemetryWorktreeSnapshot>,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
pub struct RulesLoadingError {
|
|
pub message: SharedString,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub enum SkillLoadingIssueKind {
|
|
LoadFailed,
|
|
DescriptionTooLong,
|
|
CatalogBudgetExceeded,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
pub struct SkillLoadingIssue {
|
|
pub project_id: EntityId,
|
|
pub path: PathBuf,
|
|
pub message: SharedString,
|
|
pub kind: SkillLoadingIssueKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct SkillLoadingIssueData {
|
|
path: PathBuf,
|
|
message: String,
|
|
kind: SkillLoadingIssueKind,
|
|
}
|
|
|
|
impl SkillLoadingIssueData {
|
|
fn from_load_error(error: SkillLoadError) -> Self {
|
|
Self {
|
|
path: error.path,
|
|
message: error.message,
|
|
kind: SkillLoadingIssueKind::LoadFailed,
|
|
}
|
|
}
|
|
|
|
fn from_load_warning(skill: &Skill, warning: &SkillLoadWarning) -> Self {
|
|
let kind = match warning {
|
|
SkillLoadWarning::DescriptionTooLong { .. } => {
|
|
SkillLoadingIssueKind::DescriptionTooLong
|
|
}
|
|
};
|
|
Self {
|
|
path: skill.skill_file_path.clone(),
|
|
message: warning.message(),
|
|
kind,
|
|
}
|
|
}
|
|
|
|
fn catalog_budget_exceeded(path: PathBuf, message: String) -> Self {
|
|
Self {
|
|
path,
|
|
message,
|
|
kind: SkillLoadingIssueKind::CatalogBudgetExceeded,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emitted whenever the set of skill loading issues for a project changes.
|
|
/// The `issues` field is the full replacement list; subscribers should treat
|
|
/// it as a snapshot rather than appending. An empty `issues` list means all
|
|
/// previously-reported issues have been resolved.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SkillLoadingIssuesUpdated {
|
|
pub project_id: EntityId,
|
|
pub issues: Vec<SkillLoadingIssue>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct NativeAvailableSkill {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub source: SharedString,
|
|
pub skill_file_path: PathBuf,
|
|
pub warning: Option<SharedString>,
|
|
}
|
|
|
|
impl From<&Skill> for NativeAvailableSkill {
|
|
fn from(skill: &Skill) -> Self {
|
|
Self {
|
|
name: skill.name.clone(),
|
|
description: skill.description.clone(),
|
|
source: skill.source.display_label().to_string().into(),
|
|
skill_file_path: skill.skill_file_path.clone(),
|
|
warning: skill
|
|
.load_warnings
|
|
.first()
|
|
.map(|warning| warning.message().into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const COMPACT_COMMAND_NAME: &str = "compact";
|
|
|
|
/// Returns the set of MCP prompt names that must be server-qualified
|
|
/// (`/<server>.<name>`) to stay unambiguous in the slash-command popup: names
|
|
/// shared by more than one MCP prompt, or names colliding with a reserved
|
|
/// built-in command (e.g. `/compact`). A built-in always wins an unqualified
|
|
/// invocation, so colliding MCP prompts are only reachable when prefixed.
|
|
fn ambiguous_mcp_prompt_names<'a>(
|
|
reserved: impl IntoIterator<Item = &'a str>,
|
|
prompt_names: impl IntoIterator<Item = &'a str>,
|
|
) -> HashSet<&'a str> {
|
|
let mut counts: HashMap<&str, usize> = HashMap::default();
|
|
for name in reserved.into_iter().chain(prompt_names) {
|
|
*counts.entry(name).or_insert(0) += 1;
|
|
}
|
|
counts
|
|
.into_iter()
|
|
.filter_map(|(name, count)| (count > 1).then_some(name))
|
|
.collect()
|
|
}
|
|
|
|
struct ProjectState {
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
skills: Arc<Vec<Skill>>,
|
|
skill_loading_issues: Vec<SkillLoadingIssue>,
|
|
project_context_needs_refresh: watch::Sender<()>,
|
|
_maintain_project_context: Task<Result<()>>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
/// A thread snapshot captured for persistence. Building it clones the thread's
|
|
/// messages (cheap `Arc` clones) so the background save can serialize without
|
|
/// racing the foreground mutation of the live thread.
|
|
struct PendingThreadSave {
|
|
folder_paths: PathList,
|
|
db_thread: Task<DbThread>,
|
|
}
|
|
|
|
/// Holds both the internal Thread and the AcpThread for a session
|
|
struct Session {
|
|
/// The internal thread that processes messages
|
|
thread: Entity<Thread>,
|
|
/// The ACP thread that handles protocol communication
|
|
acp_thread: WeakEntity<acp_thread::AcpThread>,
|
|
subagents: Vec<Entity<acp_thread::AcpThread>>,
|
|
project_id: EntityId,
|
|
/// Latest snapshot to persist. Overwritten in place on every save request;
|
|
/// the single save worker drains it, coalescing bursts into one write.
|
|
pending_save: Arc<Mutex<Option<PendingThreadSave>>>,
|
|
last_streaming_save_key: Option<StreamingSaveKey>,
|
|
last_draft_prompt_revision: Option<usize>,
|
|
save_wake: watch::Sender<()>,
|
|
save_worker: Task<Result<()>>,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
impl Session {
|
|
fn draft_prompt(&self, cx: &App) -> Option<Vec<acp_v2::ContentBlock>> {
|
|
match self.acp_thread.upgrade() {
|
|
Some(acp_thread) => acp_thread.read(cx).draft_prompt().map(Vec::from),
|
|
None => self.thread.read(cx).draft_prompt().map(Vec::from),
|
|
}
|
|
}
|
|
|
|
fn draft_prompt_revision(&self, cx: &App) -> Option<usize> {
|
|
self.acp_thread
|
|
.upgrade()
|
|
.map(|acp_thread| acp_thread.read(cx).draft_prompt_revision())
|
|
}
|
|
|
|
fn can_skip_save_while_streaming(&self, cx: &App) -> bool {
|
|
// Streaming notifies the thread once per chunk, but `to_db` doesn't
|
|
// include the streaming message, so those notifies have nothing new to save.
|
|
let thread = self.thread.read(cx);
|
|
thread.is_streaming_message()
|
|
&& self
|
|
.last_streaming_save_key
|
|
.as_ref()
|
|
.is_some_and(|key| *key == thread.streaming_save_key())
|
|
&& self.last_draft_prompt_revision == self.draft_prompt_revision(cx)
|
|
}
|
|
}
|
|
|
|
struct PendingSession {
|
|
task: Shared<Task<Result<Entity<AcpThread>, Arc<anyhow::Error>>>>,
|
|
}
|
|
|
|
pub struct LanguageModels {
|
|
/// Access language model by ID
|
|
models: HashMap<AgentModelId, LanguageModel>,
|
|
/// Cached list for returning language model information
|
|
model_list: acp_thread::AgentModelList,
|
|
refresh_models_rx: watch::Receiver<()>,
|
|
refresh_models_tx: watch::Sender<()>,
|
|
_authenticate_all_providers_task: Task<()>,
|
|
}
|
|
|
|
impl LanguageModels {
|
|
fn new(cx: &mut App) -> Self {
|
|
let (refresh_models_tx, refresh_models_rx) = watch::channel(());
|
|
|
|
let mut this = Self {
|
|
models: HashMap::default(),
|
|
model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()),
|
|
refresh_models_rx,
|
|
refresh_models_tx,
|
|
_authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx),
|
|
};
|
|
this.refresh_list(cx);
|
|
this
|
|
}
|
|
|
|
fn refresh_list(&mut self, cx: &App) {
|
|
let providers = LanguageModelRegistry::global(cx)
|
|
.read(cx)
|
|
.visible_providers()
|
|
.into_iter()
|
|
.filter(|provider| provider.is_authenticated(cx))
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut language_model_list = IndexMap::default();
|
|
let mut recommended_models = HashSet::default();
|
|
|
|
let mut recommended = Vec::new();
|
|
for provider in &providers {
|
|
for model in provider.recommended_models(cx) {
|
|
recommended_models.insert((model.provider_id(), model.id()));
|
|
recommended.push(Self::map_language_model_to_info(&model, provider));
|
|
}
|
|
}
|
|
if !recommended.is_empty() {
|
|
language_model_list.insert(
|
|
acp_thread::AgentModelGroupName("Recommended".into()),
|
|
recommended,
|
|
);
|
|
}
|
|
|
|
let mut models = HashMap::default();
|
|
for provider in providers {
|
|
let mut provider_models = Vec::new();
|
|
for model in provider.provided_models(cx) {
|
|
let model_info = Self::map_language_model_to_info(&model, &provider);
|
|
let model_id = model_info.id.clone();
|
|
provider_models.push(model_info);
|
|
models.insert(model_id, model);
|
|
}
|
|
if !provider_models.is_empty() {
|
|
language_model_list.insert(
|
|
acp_thread::AgentModelGroupName(provider.name().0.clone()),
|
|
provider_models,
|
|
);
|
|
}
|
|
}
|
|
|
|
self.models = models;
|
|
self.model_list = acp_thread::AgentModelList::Grouped(language_model_list);
|
|
self.refresh_models_tx.send(()).ok();
|
|
}
|
|
|
|
fn watch(&self) -> watch::Receiver<()> {
|
|
self.refresh_models_rx.clone()
|
|
}
|
|
|
|
pub fn notify_model_selection_changed(&mut self) {
|
|
self.refresh_models_tx.send(()).ok();
|
|
}
|
|
|
|
pub fn model_from_id(&self, model_id: &AgentModelId) -> Option<LanguageModel> {
|
|
self.models.get(model_id).cloned()
|
|
}
|
|
|
|
fn map_language_model_to_info(
|
|
model: &LanguageModel,
|
|
provider: &Arc<dyn LanguageModelProvider>,
|
|
) -> acp_thread::AgentModelInfo {
|
|
acp_thread::AgentModelInfo {
|
|
id: Self::model_id(model),
|
|
name: model.name().0,
|
|
description: None,
|
|
icon: Some(match provider.icon() {
|
|
IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path),
|
|
IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
|
|
}),
|
|
is_latest: model.is_latest(),
|
|
cost: model.model_cost_info().map(|cost| cost.to_shared_string()),
|
|
disabled: model.is_disabled(),
|
|
}
|
|
}
|
|
|
|
fn model_id(model: &LanguageModel) -> AgentModelId {
|
|
AgentModelId::new(format!("{}/{}", model.provider_id().0, model.id().0))
|
|
}
|
|
|
|
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
|
|
let authenticate_all_providers = LanguageModelRegistry::global(cx)
|
|
.read(cx)
|
|
.visible_providers()
|
|
.iter()
|
|
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
|
|
.collect::<Vec<_>>();
|
|
|
|
cx.spawn(async move |cx| {
|
|
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
|
|
if let Err(err) = authenticate_task.await {
|
|
match err {
|
|
language_model::AuthenticateError::CredentialsNotFound => {
|
|
// Since we're authenticating these providers in the
|
|
// background for the purposes of populating the
|
|
// language selector, we don't care about providers
|
|
// where the credentials are not found.
|
|
}
|
|
language_model::AuthenticateError::ConnectionRefused => {
|
|
// Not logging connection refused errors as they are mostly from LM Studio's noisy auth failures.
|
|
// LM Studio only has one auth method (endpoint call) which fails for users who haven't enabled it.
|
|
// TODO: Better manage LM Studio auth logic to avoid these noisy failures.
|
|
}
|
|
_ => {
|
|
// Some providers have noisy failure states that we
|
|
// don't want to spam the logs with every time the
|
|
// language model selector is initialized.
|
|
//
|
|
// Ideally these should have more clear failure modes
|
|
// that we know are safe to ignore here, like what we do
|
|
// with `CredentialsNotFound` above.
|
|
match provider_id.0.as_ref() {
|
|
"lmstudio" | "ollama" => {
|
|
// LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
|
|
//
|
|
// These fail noisily, so we don't log them.
|
|
}
|
|
"copilot_chat" => {
|
|
// Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
|
|
}
|
|
_ => {
|
|
log::error!(
|
|
"Failed to authenticate provider: {}: {err:#}",
|
|
provider_name.0
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx)
|
|
.update(cx, |registry, cx| registry.refresh_fallback_model(cx))
|
|
});
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Implemented by the UI layer to provide the ability for agent tools to create
|
|
/// sibling threads that appear in the agent panel.
|
|
///
|
|
/// `agent_ui::AgentPanel` installs an implementation of this trait on the
|
|
/// `NativeAgent` when it sets up a connection. Tools in a native-agent thread
|
|
/// then discover and use the host via `NativeThreadEnvironment`. The UI side
|
|
/// is responsible for keeping the installed host current; a host whose
|
|
/// backing UI has been torn down will fail its first request with a clear
|
|
/// error rather than being detected up front.
|
|
pub trait SiblingThreadHost {
|
|
fn create_sibling_thread(
|
|
&self,
|
|
request: SiblingThreadRequest,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Result<SiblingThreadInfo>>;
|
|
|
|
fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents>;
|
|
}
|
|
|
|
pub struct NativeAgent {
|
|
/// Session ID -> Session mapping
|
|
sessions: HashMap<acp_v2::SessionId, Session>,
|
|
pending_sessions: HashMap<acp_v2::SessionId, PendingSession>,
|
|
thread_store: Entity<ThreadStore>,
|
|
/// Project-specific state keyed by project EntityId
|
|
projects: HashMap<EntityId, ProjectState>,
|
|
/// Shared templates for all threads
|
|
templates: Arc<Templates>,
|
|
/// Cached model information
|
|
models: LanguageModels,
|
|
/// Handler installed by the UI for `create_thread` / `list_agents_and_models` tools.
|
|
sibling_thread_host: Option<Rc<dyn SiblingThreadHost>>,
|
|
fs: Arc<dyn Fs>,
|
|
_subscriptions: Vec<Subscription>,
|
|
/// Tracks the lifecycle of global skills directory observation. We
|
|
/// don't eagerly watch (or even check for) `~/.agents/skills/` at
|
|
/// startup; users who never engage with the agent panel pay zero
|
|
/// filesystem cost. The watch is kicked off lazily by
|
|
/// [`Self::ensure_skills_scan_started`], which is called from the
|
|
/// three agent-panel interaction points: input box focus, slash
|
|
/// autocomplete, and conversation submit.
|
|
skills_state: SkillsState,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
enum SkillsState {
|
|
/// No scan or watch is active. A user-interaction trigger will kick
|
|
/// off a fresh scan.
|
|
#[default]
|
|
Idle,
|
|
/// A one-shot scan task is in flight. It checks whether
|
|
/// `~/.agents/skills/` exists; if so, transitions to `Watching`,
|
|
/// otherwise back to `Idle`.
|
|
Scanning,
|
|
/// A watch task is observing `~/.agents/skills/`. It transitions
|
|
/// back to `Idle` if the watched directory itself is removed.
|
|
Watching,
|
|
}
|
|
|
|
impl gpui::EventEmitter<SkillLoadingIssuesUpdated> for NativeAgent {}
|
|
|
|
static RULES_FILE_REL_PATHS: LazyLock<Vec<Arc<RelPath>>> = LazyLock::new(|| {
|
|
RULES_FILE_NAMES
|
|
.iter()
|
|
.filter_map(|name| {
|
|
RelPath::from_unix_str(name)
|
|
.ok()
|
|
.map(|path| path.into_arc())
|
|
})
|
|
.collect()
|
|
});
|
|
|
|
static AGENTS_PREFIX: LazyLock<Option<Arc<RelPath>>> = LazyLock::new(|| {
|
|
RelPath::from_unix_str(AGENTS_DIR_NAME)
|
|
.ok()
|
|
.map(|path| path.into_arc())
|
|
});
|
|
|
|
static SKILLS_PREFIX: LazyLock<Option<Arc<RelPath>>> = LazyLock::new(|| {
|
|
RelPath::from_unix_str(project_skills_relative_path())
|
|
.ok()
|
|
.map(|path| path.into_arc())
|
|
});
|
|
|
|
struct ProjectSkillFile {
|
|
relative_path: Arc<RelPath>,
|
|
display_path: PathBuf,
|
|
size: u64,
|
|
}
|
|
|
|
async fn expand_worktree_directory(
|
|
worktree: &Entity<Worktree>,
|
|
path: &RelPath,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
let expand_task = worktree.update(cx, |worktree, cx| {
|
|
let entry_id = worktree
|
|
.entry_for_path(path)
|
|
.filter(|entry| entry.is_dir())
|
|
.map(|entry| entry.id);
|
|
entry_id.and_then(|entry_id| worktree.expand_entry(entry_id, cx))
|
|
});
|
|
|
|
if let Some(expand_task) = expand_task {
|
|
expand_task.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn expand_project_skills_directories(
|
|
worktree: &Entity<Worktree>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
let agents_dir = RelPath::from_unix_str(AGENTS_DIR_NAME)?;
|
|
let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
|
|
expand_worktree_directory(worktree, agents_dir, cx).await?;
|
|
expand_worktree_directory(worktree, skills_prefix, cx).await?;
|
|
|
|
let skill_dirs = worktree.update(cx, |worktree, _cx| {
|
|
worktree
|
|
.child_entries(skills_prefix)
|
|
.filter(|entry| entry.is_dir())
|
|
.map(|entry| entry.path.clone())
|
|
.collect::<Vec<_>>()
|
|
});
|
|
for skill_dir in skill_dirs {
|
|
expand_worktree_directory(worktree, &skill_dir, cx).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn project_skill_files_from_worktree(worktree: &Worktree) -> Vec<ProjectSkillFile> {
|
|
let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(skill_file_name) = RelPath::from_unix_str(SKILL_FILE_NAME) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut skill_files = Vec::new();
|
|
for skill_dir in worktree.child_entries(skills_prefix) {
|
|
if !skill_dir.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let relative_path = skill_dir.path.join(skill_file_name);
|
|
let Some(skill_file) = worktree.entry_for_path(&relative_path) else {
|
|
continue;
|
|
};
|
|
if !skill_file.is_file() {
|
|
continue;
|
|
}
|
|
|
|
skill_files.push(ProjectSkillFile {
|
|
display_path: worktree.absolutize(&relative_path),
|
|
relative_path: relative_path.into(),
|
|
size: skill_file.size,
|
|
});
|
|
}
|
|
|
|
skill_files.sort_by(|a, b| {
|
|
a.relative_path
|
|
.as_unix_str()
|
|
.cmp(b.relative_path.as_unix_str())
|
|
});
|
|
skill_files
|
|
}
|
|
|
|
impl NativeAgent {
|
|
pub fn new(
|
|
thread_store: Entity<ThreadStore>,
|
|
templates: Arc<Templates>,
|
|
fs: Arc<dyn Fs>,
|
|
cx: &mut App,
|
|
) -> Entity<NativeAgent> {
|
|
log::debug!("Creating new NativeAgent");
|
|
|
|
cx.new(|cx| {
|
|
let subscriptions = vec![
|
|
cx.subscribe(
|
|
&LanguageModelRegistry::global(cx),
|
|
Self::handle_models_updated_event,
|
|
),
|
|
// Flush thread content on quit so an in-flight async save
|
|
// can't leave a thread orphaned ("no thread found with ID").
|
|
cx.on_app_quit(Self::flush_threads_on_quit),
|
|
];
|
|
|
|
if !cx.has_global::<SkillIndex>() {
|
|
cx.set_global(SkillIndex::default());
|
|
}
|
|
|
|
Self {
|
|
sessions: HashMap::default(),
|
|
pending_sessions: HashMap::default(),
|
|
thread_store,
|
|
projects: HashMap::default(),
|
|
templates,
|
|
models: LanguageModels::new(cx),
|
|
sibling_thread_host: None,
|
|
fs,
|
|
_subscriptions: subscriptions,
|
|
skills_state: SkillsState::default(),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Kicks off a one-time scan of the global skills directory if one
|
|
/// isn't already in progress and a watch isn't already active.
|
|
///
|
|
/// Idempotent and cheap: returns immediately if a scan or watch is
|
|
/// already running. The expected callers are user-interaction events
|
|
/// from the agent panel (input focus, slash autocomplete, conversation
|
|
/// submit); firing this from any of them is equivalent and safe to
|
|
/// repeat.
|
|
///
|
|
/// The scan itself runs detached on the foreground executor. If
|
|
/// `~/.agents/skills/` exists it transitions state to
|
|
/// [`SkillsState::Watching`] and starts a recursive watch;
|
|
/// otherwise it transitions back to [`SkillsState::Idle`] so the
|
|
/// next trigger retries (covering the case where the user creates
|
|
/// the directory after the first scan).
|
|
pub fn ensure_skills_scan_started(&mut self, cx: &mut Context<Self>) {
|
|
if !matches!(self.skills_state, SkillsState::Idle) {
|
|
return;
|
|
}
|
|
self.skills_state = SkillsState::Scanning;
|
|
let fs = self.fs.clone();
|
|
cx.spawn(async move |this, cx| Self::run_skills_scan(this, fs, cx).await)
|
|
.detach();
|
|
}
|
|
|
|
async fn run_skills_scan(this: WeakEntity<Self>, fs: Arc<dyn Fs>, cx: &mut AsyncApp) {
|
|
let skills_dir = global_skills_dir();
|
|
if !fs.is_dir(&skills_dir).await {
|
|
// Skills directory doesn't exist; revert state so the next
|
|
// user trigger retries.
|
|
let _ = this.update(cx, |this, _cx| {
|
|
this.skills_state = SkillsState::Idle;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skills directory exists. Start a watch and trigger a refresh
|
|
// of every project's context so the freshly-discovered skills
|
|
// get loaded.
|
|
let _ = this.update(cx, |this, cx| {
|
|
cx.spawn({
|
|
let fs = fs.clone();
|
|
let skills_dir = skills_dir.clone();
|
|
async move |this, cx| Self::run_skills_watch(this, fs, skills_dir, cx).await
|
|
})
|
|
.detach();
|
|
this.skills_state = SkillsState::Watching;
|
|
for state in this.projects.values_mut() {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn run_skills_watch(
|
|
this: WeakEntity<Self>,
|
|
fs: Arc<dyn Fs>,
|
|
skills_dir: PathBuf,
|
|
cx: &mut AsyncApp,
|
|
) {
|
|
let (mut events, watcher) = fs
|
|
.watch(&skills_dir, std::time::Duration::from_millis(500))
|
|
.await;
|
|
|
|
// Linux's inotify backend is non-recursive, so a watch on
|
|
// `skills_dir` only fires for direct children. Skill discovery
|
|
// is intentionally one level deep (`<skills_dir>/<skill>/SKILL.md`),
|
|
// so we only register watches on each immediate child directory
|
|
// and deliberately do NOT recurse: a stray `node_modules`,
|
|
// `target`, or `.git` inside a skill folder would otherwise
|
|
// register watches for tens of thousands of subdirectories.
|
|
// These per-child adds are cheap no-ops on macOS/Windows where
|
|
// the OS-level watch is already recursive.
|
|
if let Ok(mut entries) = fs.read_dir(&skills_dir).await {
|
|
while let Some(entry) = entries.next().await {
|
|
let Ok(path) = entry else { continue };
|
|
if let Ok(Some(metadata)) = fs.metadata(&path).await
|
|
&& metadata.is_dir
|
|
{
|
|
watcher.add(&path).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Some(events) = events.next().await {
|
|
// When a new immediate child directory of `skills_dir` is
|
|
// created, add a single watch for it so changes to its
|
|
// `SKILL.md` are observed on Linux. We intentionally do not
|
|
// recurse into the new directory — skill discovery is only
|
|
// one level deep.
|
|
for event in &events {
|
|
if event.kind == Some(fs::PathEventKind::Created)
|
|
&& event.path.parent() == Some(skills_dir.as_path())
|
|
&& fs.is_dir(&event.path).await
|
|
{
|
|
watcher.add(&event.path).ok();
|
|
}
|
|
}
|
|
|
|
let watched_root_removed = events.iter().any(|event| {
|
|
event.path == skills_dir && event.kind == Some(fs::PathEventKind::Removed)
|
|
});
|
|
|
|
let updated = this.update(cx, |this, _cx| {
|
|
for state in this.projects.values_mut() {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
if watched_root_removed {
|
|
// Drop back to Idle so the next user trigger
|
|
// retries the scan; the next trigger will rediscover
|
|
// the directory if the user has recreated it.
|
|
this.skills_state = SkillsState::Idle;
|
|
}
|
|
});
|
|
if updated.is_err() || watched_root_removed {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn set_sibling_thread_host(&mut self, host: Rc<dyn SiblingThreadHost>) {
|
|
self.sibling_thread_host = Some(host);
|
|
}
|
|
|
|
pub fn sibling_thread_host(&self) -> Option<Rc<dyn SiblingThreadHost>> {
|
|
self.sibling_thread_host.clone()
|
|
}
|
|
|
|
fn new_session(
|
|
&mut self,
|
|
project: Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<AcpThread> {
|
|
let project_id = self.get_or_create_project_state(&project, cx);
|
|
let project_state = &self.projects[&project_id];
|
|
|
|
let registry = LanguageModelRegistry::read_global(cx);
|
|
let available_count = registry.available_models(cx).count();
|
|
log::debug!("Total available models: {}", available_count);
|
|
|
|
let default_model = registry.default_model().and_then(|default_model| {
|
|
self.models
|
|
.model_from_id(&LanguageModels::model_id(&default_model))
|
|
});
|
|
let thread = cx.new(|cx| {
|
|
Thread::new(
|
|
project,
|
|
project_state.project_context.clone(),
|
|
project_state.context_server_registry.clone(),
|
|
self.templates.clone(),
|
|
default_model,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
self.register_session(thread, project_id, cx)
|
|
}
|
|
|
|
fn register_session(
|
|
&mut self,
|
|
thread_handle: Entity<Thread>,
|
|
project_id: EntityId,
|
|
cx: &mut Context<Self>,
|
|
) -> Entity<AcpThread> {
|
|
let connection = Rc::new(NativeAgentConnection(cx.entity()));
|
|
|
|
let thread = thread_handle.read(cx);
|
|
let session_id = thread.id().clone();
|
|
let parent_session_id = thread.parent_thread_id();
|
|
let title = thread.title();
|
|
let draft_prompt = thread.draft_prompt().map(Vec::from);
|
|
let scroll_position = thread.ui_scroll_position();
|
|
let token_usage = thread.latest_token_usage();
|
|
let project = thread.project.clone();
|
|
let action_log = thread.action_log.clone();
|
|
let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone();
|
|
let acp_thread = cx.new(|cx| {
|
|
let mut acp_thread = acp_thread::AcpThread::new(
|
|
parent_session_id,
|
|
title,
|
|
None,
|
|
connection,
|
|
project.clone(),
|
|
action_log.clone(),
|
|
session_id.clone(),
|
|
prompt_capabilities_rx,
|
|
cx,
|
|
);
|
|
acp_thread.set_draft_prompt(draft_prompt, cx);
|
|
acp_thread.set_ui_scroll_position(scroll_position);
|
|
acp_thread.update_token_usage(token_usage, cx);
|
|
acp_thread
|
|
});
|
|
|
|
let registry = LanguageModelRegistry::read_global(cx);
|
|
let summarization_model = registry.thread_summary_model(cx);
|
|
|
|
let weak = cx.weak_entity();
|
|
let weak_thread = thread_handle.downgrade();
|
|
thread_handle.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(summarization_model, cx);
|
|
thread.add_default_tools(
|
|
Rc::new(NativeThreadEnvironment {
|
|
acp_thread: acp_thread.downgrade(),
|
|
thread: weak_thread,
|
|
agent: weak.clone(),
|
|
}) as _,
|
|
cx,
|
|
);
|
|
// The resolver closure reads `state.skills` at invocation
|
|
// time, so skills added or removed by the SKILL.md watcher
|
|
// after the thread is constructed are still visible to the
|
|
// model — without this, the catalog and tool would drift out
|
|
// of sync until the session was reopened.
|
|
thread.add_tool(SkillTool::with_body_resolver(
|
|
skills_resolver_for_project(weak.clone(), project_id),
|
|
skill_body_resolver_for_project(project.clone(), self.fs.clone()),
|
|
));
|
|
});
|
|
|
|
let subscriptions = vec![
|
|
cx.subscribe(&thread_handle, Self::handle_thread_title_updated),
|
|
cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated),
|
|
cx.observe(&thread_handle, move |this, thread, cx| {
|
|
this.save_thread(thread, cx)
|
|
}),
|
|
cx.observe_release(&acp_thread, {
|
|
let session_id = session_id.clone();
|
|
let acp_thread_id = acp_thread.entity_id();
|
|
move |this, released_acp_thread, cx| {
|
|
let draft_prompt = released_acp_thread.draft_prompt().map(Vec::from);
|
|
this.release_session(&session_id, acp_thread_id, draft_prompt, cx);
|
|
}
|
|
}),
|
|
];
|
|
|
|
let (save_wake, save_wake_rx) = watch::channel(());
|
|
let pending_save: Arc<Mutex<Option<PendingThreadSave>>> = Arc::new(Mutex::new(None));
|
|
let database_future = ThreadsDatabase::connect(cx);
|
|
let thread_store = self.thread_store.clone();
|
|
let save_worker = cx.spawn({
|
|
let pending_save = pending_save.clone();
|
|
let session_id = session_id.clone();
|
|
async move |_this, cx| {
|
|
Self::run_save_worker(
|
|
session_id,
|
|
save_wake_rx,
|
|
pending_save,
|
|
database_future,
|
|
thread_store,
|
|
cx,
|
|
)
|
|
.await
|
|
}
|
|
});
|
|
|
|
self.sessions.insert(
|
|
session_id,
|
|
Session {
|
|
thread: thread_handle,
|
|
acp_thread: acp_thread.downgrade(),
|
|
subagents: Vec::new(),
|
|
project_id,
|
|
pending_save,
|
|
last_streaming_save_key: None,
|
|
last_draft_prompt_revision: None,
|
|
save_wake,
|
|
save_worker,
|
|
_subscriptions: subscriptions,
|
|
},
|
|
);
|
|
|
|
self.update_available_commands_for_project(project_id, cx);
|
|
|
|
acp_thread
|
|
}
|
|
|
|
pub fn models(&self) -> &LanguageModels {
|
|
&self.models
|
|
}
|
|
|
|
fn get_or_create_project_state(
|
|
&mut self,
|
|
project: &Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> EntityId {
|
|
let project_id = project.entity_id();
|
|
if self.projects.contains_key(&project_id) {
|
|
return project_id;
|
|
}
|
|
|
|
let project_context = cx.new(|_| ProjectContext::new(vec![]));
|
|
self.register_project_with_initial_context(project.clone(), project_context, cx);
|
|
if let Some(state) = self.projects.get_mut(&project_id) {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
project_id
|
|
}
|
|
|
|
fn register_project_with_initial_context(
|
|
&mut self,
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let project_id = project.entity_id();
|
|
|
|
let context_server_store = project.read(cx).context_server_store();
|
|
let context_server_registry =
|
|
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
|
|
|
|
let mut subscriptions = vec![
|
|
cx.subscribe(&project, Self::handle_project_event),
|
|
cx.subscribe(
|
|
&context_server_store,
|
|
Self::handle_context_server_store_updated,
|
|
),
|
|
cx.subscribe(
|
|
&context_server_registry,
|
|
Self::handle_context_server_registry_event,
|
|
),
|
|
];
|
|
// When the user trusts a worktree (or revokes trust), project-local
|
|
// skills become eligible (or ineligible) for loading. Trigger a
|
|
// refresh so the catalog and slash-command list update without a
|
|
// restart. This is unconditional — a `Trusted` event for any
|
|
// worktree under any project is cheap to handle and keeps the
|
|
// logic straightforward.
|
|
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
|
subscriptions.push(
|
|
cx.subscribe(&trusted_worktrees, move |this, _, _event, _cx| {
|
|
if let Some(state) = this.projects.get_mut(&project_id) {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
|
|
watch::channel(());
|
|
|
|
self.projects.insert(
|
|
project_id,
|
|
ProjectState {
|
|
project,
|
|
project_context,
|
|
skills: Arc::new(Vec::new()),
|
|
skill_loading_issues: Vec::new(),
|
|
project_context_needs_refresh: project_context_needs_refresh_tx,
|
|
_maintain_project_context: cx.spawn(async move |this, cx| {
|
|
Self::maintain_project_context(
|
|
this,
|
|
project_id,
|
|
project_context_needs_refresh_rx,
|
|
cx,
|
|
)
|
|
.await
|
|
}),
|
|
context_server_registry,
|
|
_subscriptions: subscriptions,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn session_project_state(&self, session_id: &acp_v2::SessionId) -> Option<&ProjectState> {
|
|
self.sessions
|
|
.get(session_id)
|
|
.and_then(|session| self.projects.get(&session.project_id))
|
|
}
|
|
|
|
async fn maintain_project_context(
|
|
this: WeakEntity<Self>,
|
|
project_id: EntityId,
|
|
mut needs_refresh: watch::Receiver<()>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
while needs_refresh.changed().await.is_ok() {
|
|
let task = this.update(cx, |this, cx| {
|
|
let state = this
|
|
.projects
|
|
.get(&project_id)
|
|
.context("project state not found")?;
|
|
anyhow::Ok(Self::build_project_context(
|
|
&state.project,
|
|
this.fs.clone(),
|
|
cx,
|
|
))
|
|
})??;
|
|
let (project_context, skills, skill_issue_data) = task.await;
|
|
let skills = Arc::new(skills);
|
|
let skill_loading_issues: Vec<SkillLoadingIssue> = skill_issue_data
|
|
.into_iter()
|
|
.map(|issue| SkillLoadingIssue {
|
|
project_id,
|
|
path: issue.path,
|
|
message: issue.message.into(),
|
|
kind: issue.kind,
|
|
})
|
|
.collect();
|
|
this.update(cx, |this, cx| {
|
|
// Only emit SkillLoadingIssuesUpdated when the issue list
|
|
// actually changed. Refreshes happen frequently (prompt-store
|
|
// updates, rules-file edits, worktree events, trust-state
|
|
// changes), and re-emitting an unchanged list causes the UI
|
|
// to redisplay issues the user has already dismissed.
|
|
// Transitions from non-empty to empty still count as a change,
|
|
// so subscribers continue to receive an empty list to clear
|
|
// previously-displayed issues when they get resolved.
|
|
let issues_changed = this
|
|
.projects
|
|
.get(&project_id)
|
|
.map(|state| state.skill_loading_issues != skill_loading_issues)
|
|
.unwrap_or(true);
|
|
|
|
if let Some(state) = this.projects.get_mut(&project_id) {
|
|
state.skills = skills;
|
|
state.skill_loading_issues = skill_loading_issues.clone();
|
|
// Only push the new `ProjectContext` through if it
|
|
// differs from the current one. The system prompt is
|
|
// re-rendered from this on every turn, so an unchanged
|
|
// `ProjectContext` means a byte-identical system prompt
|
|
// and a continued hit on the model API's prompt cache.
|
|
// Refreshes fire on many events that don't actually
|
|
// change what the model sees (e.g. a SKILL.md body edit
|
|
// that leaves the catalog — name, description, location
|
|
// — untouched), so this check matters in practice.
|
|
state
|
|
.project_context
|
|
.update(cx, |current_project_context, cx| {
|
|
if *current_project_context != project_context {
|
|
*current_project_context = project_context;
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
if issues_changed {
|
|
cx.emit(SkillLoadingIssuesUpdated {
|
|
project_id,
|
|
issues: skill_loading_issues,
|
|
});
|
|
}
|
|
// Skills appear in the slash-command list, so a change in
|
|
// the loaded skills needs to be pushed out to active sessions.
|
|
// This runs unconditionally because MCP prompts (also part of
|
|
// the available commands) can change without affecting the
|
|
// skill error list.
|
|
this.update_available_commands_for_project(project_id, cx);
|
|
this.publish_skill_index(cx);
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn build_project_context(
|
|
project: &Entity<Project>,
|
|
fs: Arc<dyn Fs>,
|
|
cx: &mut App,
|
|
) -> Task<(ProjectContext, Vec<Skill>, Vec<SkillLoadingIssueData>)> {
|
|
let worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
|
|
let worktree_tasks = worktrees
|
|
.iter()
|
|
.map(|worktree| {
|
|
Self::load_worktree_info_for_system_prompt(worktree.clone(), project.clone(), cx)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
// Load global skills
|
|
let global_skills_task = {
|
|
let global_skills_dir = global_skills_dir();
|
|
let global_skills_fs = fs.clone();
|
|
cx.background_spawn(async move {
|
|
load_skills_from_directory(
|
|
&global_skills_fs,
|
|
&global_skills_dir,
|
|
SkillSource::Global,
|
|
)
|
|
.await
|
|
})
|
|
};
|
|
|
|
// Load project-local skills, but only from worktrees the user has
|
|
// trusted. Skills in `.agents/skills/` ship with the project; a
|
|
// freshly cloned untrusted repo can carry hostile descriptions or
|
|
// bodies, so we keep them out of the catalog and the slash-command
|
|
// list until trust is granted. The subscription in
|
|
// `register_project_with_initial_context` triggers a context
|
|
// refresh when a worktree's trust state changes, so newly trusted
|
|
// worktrees pick up their skills without restarting.
|
|
let trusted_worktrees = TrustedWorktrees::try_get_global(cx);
|
|
let worktree_store = project.read(cx).worktree_store();
|
|
let project_skills_task = {
|
|
let project = project.clone();
|
|
let trusted_worktrees = worktrees
|
|
.iter()
|
|
.filter_map(|worktree| {
|
|
let worktree_id = worktree.read(cx).id();
|
|
let is_trusted = trusted_worktrees.as_ref().is_none_or(|trusted_worktrees| {
|
|
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
|
trusted_worktrees.can_trust(&worktree_store, worktree_id, cx)
|
|
})
|
|
});
|
|
if !is_trusted {
|
|
return None;
|
|
}
|
|
|
|
let worktree_snapshot = worktree.read(cx);
|
|
let worktree_root_name: Arc<str> = worktree_snapshot.root_name_str().into();
|
|
let scan_complete = worktree_snapshot
|
|
.as_local()
|
|
.map(|local| local.scan_complete());
|
|
Some((
|
|
worktree.clone(),
|
|
worktree_id,
|
|
worktree_root_name,
|
|
scan_complete,
|
|
))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
cx.spawn(async move |cx| {
|
|
let mut project_skills_results = Vec::new();
|
|
for (worktree, worktree_id, worktree_root_name, scan_complete) in trusted_worktrees
|
|
{
|
|
if let Some(scan_complete) = scan_complete {
|
|
scan_complete.await;
|
|
}
|
|
if let Err(error) = expand_project_skills_directories(&worktree, cx).await {
|
|
project_skills_results.push(vec![Err(SkillLoadError {
|
|
path: PathBuf::from(project_skills_relative_path()),
|
|
message: format!("Failed to scan project skills: {}", error),
|
|
})]);
|
|
continue;
|
|
}
|
|
|
|
let skill_files = worktree.update(cx, |worktree, _cx| {
|
|
project_skill_files_from_worktree(worktree)
|
|
});
|
|
let source = SkillSource::ProjectLocal {
|
|
worktree_id: SkillScopeId(worktree_id.to_usize()),
|
|
worktree_root_name,
|
|
};
|
|
|
|
let mut worktree_results = Vec::new();
|
|
for skill_file in skill_files {
|
|
if skill_file.size > MAX_SKILL_FILE_SIZE as u64 {
|
|
worktree_results.push(Err(SkillLoadError {
|
|
path: skill_file.display_path.clone(),
|
|
message: format!(
|
|
"SKILL.md file exceeds maximum size of {}KB",
|
|
MAX_SKILL_FILE_SIZE / 1024
|
|
),
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let buffer = match project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer(
|
|
(worktree_id, skill_file.relative_path.clone()),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
{
|
|
Ok(buffer) => buffer,
|
|
Err(error) => {
|
|
worktree_results.push(Err(SkillLoadError {
|
|
path: skill_file.display_path.clone(),
|
|
message: format!("Failed to read file: {}", error),
|
|
}));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let content = cx
|
|
.update(|cx| buffer.read(cx).as_text_snapshot().as_rope().to_string());
|
|
|
|
worktree_results.push(
|
|
parse_skill_frontmatter(
|
|
&skill_file.display_path,
|
|
&content,
|
|
source.clone(),
|
|
)
|
|
.map_err(|error| SkillLoadError {
|
|
path: skill_file.display_path,
|
|
message: error.to_string(),
|
|
}),
|
|
);
|
|
}
|
|
project_skills_results.push(worktree_results);
|
|
}
|
|
project_skills_results
|
|
})
|
|
};
|
|
cx.spawn(async move |_cx| {
|
|
let worktrees = future::join_all(worktree_tasks).await;
|
|
|
|
let worktrees = worktrees
|
|
.into_iter()
|
|
.map(|(worktree, _rules_error)| {
|
|
// TODO: show error message
|
|
// if let Some(rules_error) = rules_error {
|
|
// this.update(cx, |_, cx| cx.emit(rules_error)).ok();
|
|
// }
|
|
worktree
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
// Load and combine skills. `combine_skills` deliberately
|
|
// does NOT deduplicate — the autocomplete popup needs to
|
|
// see every entry so users can disambiguate same-named
|
|
// global vs. project-local skills via the source label.
|
|
// Project-overrides-global is applied below, only for the
|
|
// model-facing catalog.
|
|
let global_skills = global_skills_task.await;
|
|
let project_skills_results = project_skills_task.await;
|
|
let (skills, skill_errors) =
|
|
combine_skills(global_skills, project_skills_results.into_iter().flatten());
|
|
let mut skill_issues = skill_errors
|
|
.into_iter()
|
|
.map(SkillLoadingIssueData::from_load_error)
|
|
.collect::<Vec<_>>();
|
|
for skill in &skills {
|
|
skill_issues.extend(
|
|
skill
|
|
.load_warnings
|
|
.iter()
|
|
.map(|warning| SkillLoadingIssueData::from_load_warning(skill, warning)),
|
|
);
|
|
}
|
|
|
|
// Apply project-overrides-global before catalog selection
|
|
// so the model sees at most one entry per name. The full
|
|
// `skills` list is still stored on `ProjectState` and used
|
|
// by the autocomplete popup.
|
|
let overridden = apply_skill_overrides(&skills);
|
|
|
|
// Enforce the catalog size budget here so that skills which
|
|
// don't fit produce an issue in the UI rather than being
|
|
// silently swallowed by ProjectContext.
|
|
let (catalog_skills, budget_issues) = select_catalog_skills(&overridden);
|
|
skill_issues.extend(budget_issues);
|
|
|
|
let project_context = ProjectContext::new(worktrees).with_skills(catalog_skills);
|
|
(project_context, skills, skill_issues)
|
|
})
|
|
}
|
|
|
|
fn load_worktree_info_for_system_prompt(
|
|
worktree: Entity<Worktree>,
|
|
project: Entity<Project>,
|
|
cx: &mut App,
|
|
) -> Task<(WorktreeContext, Option<RulesLoadingError>)> {
|
|
let tree = worktree.read(cx);
|
|
let root_name = tree.root_name_str().into();
|
|
let abs_path = tree.abs_path();
|
|
let scan_complete = tree.as_local().map(|local| local.scan_complete());
|
|
|
|
let mut context = WorktreeContext {
|
|
root_name,
|
|
abs_path,
|
|
rules_file: None,
|
|
};
|
|
|
|
cx.spawn(async move |cx| {
|
|
if let Some(scan_complete) = scan_complete {
|
|
scan_complete.await;
|
|
}
|
|
|
|
let rules_task = cx.update(|cx| Self::load_worktree_rules_file(worktree, project, cx));
|
|
|
|
let (rules_file, rules_file_error) = match rules_task {
|
|
Some(rules_task) => match rules_task.await {
|
|
Ok(rules_file) => (Some(rules_file), None),
|
|
Err(err) => (
|
|
None,
|
|
Some(RulesLoadingError {
|
|
message: format!("{err}").into(),
|
|
}),
|
|
),
|
|
},
|
|
None => (None, None),
|
|
};
|
|
context.rules_file = rules_file;
|
|
(context, rules_file_error)
|
|
})
|
|
}
|
|
|
|
fn load_worktree_rules_file(
|
|
worktree: Entity<Worktree>,
|
|
project: Entity<Project>,
|
|
cx: &mut App,
|
|
) -> Option<Task<Result<RulesFileContext>>> {
|
|
let worktree = worktree.read(cx);
|
|
let worktree_id = worktree.id();
|
|
let selected_rules_file = RULES_FILE_REL_PATHS
|
|
.iter()
|
|
.filter_map(|name| {
|
|
worktree
|
|
.entry_for_path(name)
|
|
.filter(|entry| entry.is_file())
|
|
.map(|entry| entry.path.clone())
|
|
})
|
|
.next();
|
|
|
|
// Note that Cline supports `.clinerules` being a directory, but that is not currently
|
|
// supported. This doesn't seem to occur often in GitHub repositories.
|
|
selected_rules_file.map(|path_in_worktree| {
|
|
let project_path = ProjectPath {
|
|
worktree_id,
|
|
path: path_in_worktree.clone(),
|
|
};
|
|
let buffer_task =
|
|
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
|
|
let rope_task = cx.spawn(async move |cx| {
|
|
let buffer = buffer_task.await?;
|
|
let (project_entry_id, rope) = buffer.read_with(cx, |buffer, cx| {
|
|
let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?;
|
|
anyhow::Ok((project_entry_id, buffer.as_rope().clone()))
|
|
})?;
|
|
anyhow::Ok((project_entry_id, rope))
|
|
});
|
|
// Build a string from the rope on a background thread.
|
|
cx.background_spawn(async move {
|
|
let (project_entry_id, rope) = rope_task.await?;
|
|
anyhow::Ok(RulesFileContext {
|
|
path_in_worktree,
|
|
text: rope.to_string().trim().to_string(),
|
|
project_entry_id: project_entry_id.to_usize(),
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
fn handle_thread_title_updated(
|
|
&mut self,
|
|
thread: Entity<Thread>,
|
|
_: &TitleUpdated,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let session_id = thread.read(cx).id();
|
|
let Some(session) = self.sessions.get(session_id) else {
|
|
return;
|
|
};
|
|
|
|
let thread = thread.downgrade();
|
|
let acp_thread = session.acp_thread.clone();
|
|
cx.spawn(async move |_, cx| {
|
|
let title = thread.read_with(cx, |thread, _| thread.title())?;
|
|
if let Some(title) = title {
|
|
let task =
|
|
acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
|
|
task.await?;
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn handle_thread_token_usage_updated(
|
|
&mut self,
|
|
thread: Entity<Thread>,
|
|
usage: &TokenUsageUpdated,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.sessions.get(thread.read(cx).id()) else {
|
|
return;
|
|
};
|
|
session
|
|
.acp_thread
|
|
.update(cx, |acp_thread, cx| {
|
|
acp_thread.update_token_usage(usage.0.clone(), cx);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
fn handle_project_event(
|
|
&mut self,
|
|
project: Entity<Project>,
|
|
event: &project::Event,
|
|
_cx: &mut Context<Self>,
|
|
) {
|
|
let project_id = project.entity_id();
|
|
let Some(state) = self.projects.get_mut(&project_id) else {
|
|
return;
|
|
};
|
|
match event {
|
|
project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
project::Event::WorktreeUpdatedEntries(_, items) => {
|
|
if items.iter().any(|(path, _, _)| {
|
|
let path_ref = path.as_ref();
|
|
RULES_FILE_REL_PATHS
|
|
.iter()
|
|
.any(|rules_path| path_ref == rules_path.as_ref())
|
|
|| AGENTS_PREFIX
|
|
.as_ref()
|
|
.is_some_and(|prefix| path_ref.starts_with(prefix))
|
|
}) {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_models_updated_event(
|
|
&mut self,
|
|
_registry: Entity<LanguageModelRegistry>,
|
|
event: &language_model::Event,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.models.refresh_list(cx);
|
|
|
|
let registry = LanguageModelRegistry::read_global(cx);
|
|
let default_model = registry.default_model();
|
|
let summarization_model = registry.thread_summary_model(cx);
|
|
|
|
for session in self.sessions.values_mut() {
|
|
session.thread.update(cx, |thread, cx| {
|
|
thread.ensure_model(default_model.as_ref(), cx);
|
|
if let language_model::Event::ProviderStateChanged(provider_id) = event {
|
|
thread.refresh_model(provider_id, cx);
|
|
}
|
|
|
|
if let Some(model) = summarization_model.clone() {
|
|
if thread.summarization_model().is_none()
|
|
|| matches!(event, language_model::Event::ThreadSummaryModelChanged)
|
|
{
|
|
thread.set_summarization_model(Some(model), cx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handle_context_server_store_updated(
|
|
&mut self,
|
|
store: Entity<project::context_server_store::ContextServerStore>,
|
|
_event: &project::context_server_store::ServerStatusChangedEvent,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let project_id = self.projects.iter().find_map(|(id, state)| {
|
|
if *state.context_server_registry.read(cx).server_store() == store {
|
|
Some(*id)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(project_id) = project_id {
|
|
self.update_available_commands_for_project(project_id, cx);
|
|
}
|
|
}
|
|
|
|
fn handle_context_server_registry_event(
|
|
&mut self,
|
|
registry: Entity<ContextServerRegistry>,
|
|
event: &ContextServerRegistryEvent,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
ContextServerRegistryEvent::ToolsChanged => {}
|
|
ContextServerRegistryEvent::PromptsChanged => {
|
|
let project_id = self.projects.iter().find_map(|(id, state)| {
|
|
if state.context_server_registry == registry {
|
|
Some(*id)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(project_id) = project_id {
|
|
self.update_available_commands_for_project(project_id, cx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn publish_skill_index(&self, cx: &mut Context<Self>) {
|
|
let mut global_skills = Vec::new();
|
|
let mut project_groups: Vec<ProjectSkillGroup> = Vec::new();
|
|
let mut seen_global = false;
|
|
|
|
for state in self.projects.values() {
|
|
for skill in state.skills.iter() {
|
|
match &skill.source {
|
|
SkillSource::BuiltIn => {}
|
|
SkillSource::Global => {
|
|
if !seen_global {
|
|
global_skills.push(skill.clone());
|
|
}
|
|
}
|
|
SkillSource::ProjectLocal {
|
|
worktree_id,
|
|
worktree_root_name,
|
|
} => {
|
|
if let Some(group) = project_groups
|
|
.iter_mut()
|
|
.find(|g| g.worktree_id == *worktree_id)
|
|
{
|
|
group.skills.push(skill.clone());
|
|
} else {
|
|
project_groups.push(ProjectSkillGroup {
|
|
worktree_id: *worktree_id,
|
|
worktree_root_name: SharedString::from(worktree_root_name.clone()),
|
|
skills: vec![skill.clone()],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !global_skills.is_empty() {
|
|
seen_global = true;
|
|
}
|
|
}
|
|
|
|
cx.set_global(SkillIndex {
|
|
global_skills,
|
|
project_skills: project_groups,
|
|
});
|
|
}
|
|
|
|
fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context<Self>) {
|
|
let available_commands =
|
|
Self::build_available_commands_for_project(self.projects.get(&project_id), cx);
|
|
for session in self.sessions.values() {
|
|
if session.project_id != project_id {
|
|
continue;
|
|
}
|
|
session
|
|
.acp_thread
|
|
.update(cx, |thread, cx| {
|
|
thread.update_available_commands(available_commands.clone(), cx);
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
fn build_available_commands_for_project(
|
|
project_state: Option<&ProjectState>,
|
|
cx: &App,
|
|
) -> Vec<acp_v2::AvailableCommand> {
|
|
let Some(state) = project_state else {
|
|
return Vec::new();
|
|
};
|
|
let compact_command = acp_v2::AvailableCommand::new(
|
|
COMPACT_COMMAND_NAME,
|
|
"Summarize the conversation so far to free up context",
|
|
)
|
|
.meta(acp_thread::meta_with_command_category(
|
|
acp_thread::CommandCategory::Native,
|
|
));
|
|
|
|
let registry = state.context_server_registry.read(cx);
|
|
|
|
// Reserve the built-in command name so a same-named MCP prompt is
|
|
// force-prefixed (`/<server>.compact`) and stays reachable: an
|
|
// unqualified `/compact` always routes to the native command.
|
|
let ambiguous_prompt_names = ambiguous_mcp_prompt_names(
|
|
[COMPACT_COMMAND_NAME],
|
|
registry.prompts().map(|p| p.prompt.name.as_str()),
|
|
);
|
|
|
|
let mcp_commands = registry.prompts().flat_map(|context_server_prompt| {
|
|
let prompt = &context_server_prompt.prompt;
|
|
|
|
let should_prefix = ambiguous_prompt_names.contains(prompt.name.as_str());
|
|
|
|
let name = if should_prefix {
|
|
format!("{}.{}", context_server_prompt.server_id, prompt.name)
|
|
} else {
|
|
prompt.name.clone()
|
|
};
|
|
|
|
let mut command =
|
|
acp_v2::AvailableCommand::new(name, prompt.description.clone().unwrap_or_default())
|
|
.meta(acp_thread::meta_with_command_category(
|
|
acp_thread::CommandCategory::Mcp,
|
|
));
|
|
|
|
match prompt.arguments.as_deref() {
|
|
Some([arg]) => {
|
|
let hint = format!("<{}>", arg.name);
|
|
|
|
command = command.input(acp_v2::AvailableCommandInput::Text(
|
|
acp_v2::TextCommandInput::new(hint),
|
|
));
|
|
}
|
|
Some([]) | None => {}
|
|
Some(_) => {
|
|
// skip >1 argument commands since we don't support them yet
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some(command)
|
|
});
|
|
|
|
std::iter::once(compact_command)
|
|
.chain(mcp_commands)
|
|
.collect()
|
|
}
|
|
|
|
pub fn load_thread(
|
|
&mut self,
|
|
id: acp_v2::SessionId,
|
|
project: Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Entity<Thread>>> {
|
|
let database_future = ThreadsDatabase::connect(cx);
|
|
cx.spawn(async move |this, cx| {
|
|
let database = database_future.await.map_err(|err| anyhow!(err))?;
|
|
let db_thread = database
|
|
.load_thread(id.clone())
|
|
.await?
|
|
.with_context(|| format!("no thread found with ID: {id:?}"))?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
let project_id = this.get_or_create_project_state(&project, cx);
|
|
let project_state = this
|
|
.projects
|
|
.get(&project_id)
|
|
.context("project state not found")?;
|
|
let summarization_model =
|
|
LanguageModelRegistry::read_global(cx).thread_summary_model(cx);
|
|
|
|
Ok(cx.new(|cx| {
|
|
let mut thread = Thread::from_db(
|
|
id.clone(),
|
|
db_thread,
|
|
project_state.project.clone(),
|
|
project_state.project_context.clone(),
|
|
project_state.context_server_registry.clone(),
|
|
this.templates.clone(),
|
|
cx,
|
|
);
|
|
thread.set_summarization_model(summarization_model, cx);
|
|
thread
|
|
}))
|
|
})?
|
|
})
|
|
}
|
|
|
|
pub fn open_thread(
|
|
&mut self,
|
|
id: acp_v2::SessionId,
|
|
project: Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Entity<AcpThread>>> {
|
|
if let Some(session) = self.sessions.get(&id) {
|
|
if let Some(acp_thread) = session.acp_thread.upgrade() {
|
|
return Task::ready(Ok(acp_thread));
|
|
}
|
|
let acp_thread_id = session.acp_thread.entity_id();
|
|
let draft_prompt = session.draft_prompt(cx);
|
|
self.release_session(&id, acp_thread_id, draft_prompt, cx);
|
|
}
|
|
|
|
if let Some(pending) = self.pending_sessions.get(&id) {
|
|
let task = pending.task.clone();
|
|
return cx.background_spawn(async move { task.await.map_err(|err| anyhow!(err)) });
|
|
}
|
|
|
|
let task = self.load_thread(id.clone(), project.clone(), cx);
|
|
let shared_task = cx
|
|
.spawn({
|
|
let id = id.clone();
|
|
async move |this, cx| {
|
|
let thread = match task.await {
|
|
Ok(thread) => thread,
|
|
Err(err) => {
|
|
this.update(cx, |this, _cx| {
|
|
this.pending_sessions.remove(&id);
|
|
})
|
|
.ok();
|
|
return Err(Arc::new(err));
|
|
}
|
|
};
|
|
let acp_thread = this
|
|
.update(cx, |this, cx| {
|
|
let project_id = this.get_or_create_project_state(&project, cx);
|
|
this.pending_sessions.remove(&id);
|
|
this.register_session(thread.clone(), project_id, cx)
|
|
})
|
|
.map_err(Arc::new)?;
|
|
let events = thread.update(cx, |thread, cx| thread.replay(cx));
|
|
cx.update(|cx| {
|
|
NativeAgentConnection::handle_thread_events(
|
|
events,
|
|
acp_thread.downgrade(),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(Arc::new)?;
|
|
Ok(acp_thread)
|
|
}
|
|
})
|
|
.shared();
|
|
self.pending_sessions.insert(
|
|
id,
|
|
PendingSession {
|
|
task: shared_task.clone(),
|
|
},
|
|
);
|
|
|
|
cx.background_spawn(async move { shared_task.await.map_err(|err| anyhow!(err)) })
|
|
}
|
|
|
|
pub fn thread_summary(
|
|
&mut self,
|
|
id: acp_v2::SessionId,
|
|
project: Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<SharedString>> {
|
|
let thread = self.open_thread(id.clone(), project, cx);
|
|
cx.spawn(async move |this, cx| {
|
|
let acp_thread = thread.await?;
|
|
let result = this
|
|
.update(cx, |this, cx| {
|
|
this.sessions
|
|
.get(&id)
|
|
.context("session released before summary")?
|
|
.thread
|
|
.update(cx, |thread, cx| anyhow::Ok(thread.summary(cx)))
|
|
})??
|
|
.await
|
|
.context("Failed to generate summary")?;
|
|
|
|
drop(acp_thread);
|
|
Ok(result)
|
|
})
|
|
}
|
|
|
|
fn release_session(
|
|
&mut self,
|
|
session_id: &acp_v2::SessionId,
|
|
acp_thread_id: EntityId,
|
|
draft_prompt: Option<Vec<acp_v2::ContentBlock>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.sessions.get(session_id) else {
|
|
return;
|
|
};
|
|
if session.acp_thread.entity_id() != acp_thread_id {
|
|
return;
|
|
}
|
|
|
|
self.enqueue_save(session_id, draft_prompt, cx);
|
|
let Some(session) = self.sessions.remove(session_id) else {
|
|
return;
|
|
};
|
|
let project_id = session.project_id;
|
|
session.save_worker.detach_and_log_err(cx);
|
|
|
|
let has_remaining = self.sessions.values().any(|s| s.project_id == project_id);
|
|
if !has_remaining {
|
|
self.projects.remove(&project_id);
|
|
self.publish_skill_index(cx);
|
|
}
|
|
}
|
|
|
|
fn save_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) {
|
|
let id = thread.read(cx).id().clone();
|
|
let Some(session) = self.sessions.get(&id) else {
|
|
return;
|
|
};
|
|
if session.can_skip_save_while_streaming(cx) {
|
|
return;
|
|
}
|
|
let draft_prompt = session.draft_prompt(cx);
|
|
self.enqueue_save(&id, draft_prompt, cx);
|
|
}
|
|
|
|
fn enqueue_save(
|
|
&mut self,
|
|
id: &acp_v2::SessionId,
|
|
draft_prompt: Option<Vec<acp_v2::ContentBlock>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.sessions.get(id) else {
|
|
return;
|
|
};
|
|
let Some((id, folder_paths, db_thread)) =
|
|
self.thread_save_payload(session, draft_prompt, cx)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let Some(session) = self.sessions.get_mut(&id) else {
|
|
return;
|
|
};
|
|
session.last_streaming_save_key = Some(session.thread.read(cx).streaming_save_key());
|
|
session.last_draft_prompt_revision = session.draft_prompt_revision(cx);
|
|
*session.pending_save.lock() = Some(PendingThreadSave {
|
|
folder_paths,
|
|
db_thread,
|
|
});
|
|
session.save_wake.send(()).log_err();
|
|
}
|
|
|
|
async fn run_save_worker(
|
|
id: acp_v2::SessionId,
|
|
mut wake: watch::Receiver<()>,
|
|
pending_save: Arc<Mutex<Option<PendingThreadSave>>>,
|
|
database_future: Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>>,
|
|
thread_store: Entity<ThreadStore>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
loop {
|
|
let closed = wake.changed().await.is_err();
|
|
let payload = pending_save.lock().take();
|
|
if let Some(PendingThreadSave {
|
|
folder_paths,
|
|
db_thread,
|
|
}) = payload
|
|
&& let Some(database) = database_future
|
|
.clone()
|
|
.await
|
|
.map_err(|err| anyhow!(err))
|
|
.log_err()
|
|
{
|
|
let db_thread = db_thread.await;
|
|
database
|
|
.save_thread(id.clone(), db_thread, folder_paths)
|
|
.await
|
|
.log_err();
|
|
thread_store.update(cx, |store, cx| store.reload(cx));
|
|
}
|
|
if closed {
|
|
break;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Builds everything needed to persist a session's thread content,
|
|
/// capturing the current draft prompt from the ACP thread. Returns `None`
|
|
/// if the thread is empty or its project state is gone.
|
|
fn thread_save_payload(
|
|
&self,
|
|
session: &Session,
|
|
draft_prompt: Option<Vec<acp_v2::ContentBlock>>,
|
|
cx: &mut App,
|
|
) -> Option<(acp_v2::SessionId, PathList, Task<DbThread>)> {
|
|
if session.thread.read(cx).is_empty() {
|
|
return None;
|
|
}
|
|
let state = self.projects.get(&session.project_id)?;
|
|
let folder_paths = PathList::new(
|
|
&state
|
|
.project
|
|
.read(cx)
|
|
.visible_worktrees(cx)
|
|
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
let id = session.thread.read(cx).id().clone();
|
|
let db_thread = session.thread.update(cx, |thread, cx| {
|
|
thread.set_draft_prompt(draft_prompt);
|
|
thread.to_db(cx)
|
|
});
|
|
Some((id, folder_paths, db_thread))
|
|
}
|
|
|
|
/// Commits every non-empty thread's content on shutdown so the async
|
|
/// `save_thread` losing the race can't leave metadata without content.
|
|
fn flush_threads_on_quit(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> impl Future<Output = ()> + use<> {
|
|
let database_future = ThreadsDatabase::connect(cx);
|
|
|
|
let mut saves = Vec::new();
|
|
for session in self.sessions.values() {
|
|
let draft_prompt = session.draft_prompt(cx);
|
|
saves.extend(self.thread_save_payload(session, draft_prompt, cx));
|
|
}
|
|
|
|
async move {
|
|
let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else {
|
|
return;
|
|
};
|
|
// All quit observers share `gpui::SHUTDOWN_TIMEOUT`, so run the
|
|
// saves concurrently instead of one at a time.
|
|
future::join_all(saves.into_iter().map(|(id, folder_paths, db_thread)| {
|
|
let database = database.clone();
|
|
async move {
|
|
let db_thread = db_thread.await;
|
|
database
|
|
.save_thread(id, db_thread, folder_paths)
|
|
.await
|
|
.log_err();
|
|
}
|
|
}))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
fn send_mcp_prompt(
|
|
&self,
|
|
client_user_message_id: ClientUserMessageId,
|
|
session_id: acp_v2::SessionId,
|
|
prompt_name: String,
|
|
server_id: ContextServerId,
|
|
arguments: HashMap<String, String>,
|
|
original_content: Vec<acp_v1::ContentBlock>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
let Some(state) = self.session_project_state(&session_id) else {
|
|
return Task::ready(Err(anyhow!("Project state not found for session")));
|
|
};
|
|
let server_store = state
|
|
.context_server_registry
|
|
.read(cx)
|
|
.server_store()
|
|
.clone();
|
|
let path_style = state.project.read(cx).path_style(cx);
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let prompt =
|
|
crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
|
|
|
|
let (acp_thread, thread) = this.update(cx, |this, _cx| {
|
|
let session = this
|
|
.sessions
|
|
.get(&session_id)
|
|
.context("Failed to get session")?;
|
|
let acp_thread = session
|
|
.acp_thread
|
|
.upgrade()
|
|
.context("Session was released")?;
|
|
anyhow::Ok((acp_thread, session.thread.clone()))
|
|
})??;
|
|
|
|
let mut last_is_user = true;
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.push_acp_user_block(
|
|
client_user_message_id,
|
|
original_content.into_iter().skip(1),
|
|
path_style,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
for message in prompt.messages {
|
|
let context_server::types::PromptMessage { role, content } = message;
|
|
let block = mcp_message_content_to_acp_content_block(content);
|
|
let display_block = acp_thread::content::from_v1(block.clone())?;
|
|
|
|
match role {
|
|
context_server::types::Role::User => {
|
|
let id = acp_thread::ClientUserMessageId::new();
|
|
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.push_user_content_block_with_indent(
|
|
Some(id.clone()),
|
|
display_block,
|
|
true,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.push_acp_user_block(id, [block], path_style, cx);
|
|
});
|
|
}
|
|
context_server::types::Role::Assistant => {
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.push_assistant_content_block_with_indent(
|
|
display_block,
|
|
false,
|
|
true,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.push_acp_agent_block(block, cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
last_is_user = role == context_server::types::Role::User;
|
|
}
|
|
|
|
let response_stream = thread.update(cx, |thread, cx| {
|
|
if last_is_user {
|
|
thread.send_existing(cx)
|
|
} else {
|
|
// Resume if MCP prompt did not end with a user message
|
|
thread.resume(cx)
|
|
}
|
|
})?;
|
|
|
|
let connection = this.upgrade().map(NativeAgentConnection);
|
|
cx.update(|cx| {
|
|
NativeAgentConnection::handle_thread_events(
|
|
response_stream,
|
|
acp_thread.downgrade(),
|
|
connection,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
})
|
|
}
|
|
|
|
/// Run a summary-based context compaction in response to the built-in
|
|
/// `/compact` slash command.
|
|
fn send_compact_command(
|
|
&self,
|
|
client_user_message_id: ClientUserMessageId,
|
|
session_id: acp_v2::SessionId,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
cx.spawn(async move |this, cx| {
|
|
let (acp_thread, thread) = this.update(cx, |this, _cx| {
|
|
let session = this
|
|
.sessions
|
|
.get(&session_id)
|
|
.context("Failed to get session")?;
|
|
let acp_thread = session
|
|
.acp_thread
|
|
.upgrade()
|
|
.context("Session was released")?;
|
|
anyhow::Ok((acp_thread, session.thread.clone()))
|
|
})??;
|
|
|
|
let response_stream =
|
|
thread.update(cx, |thread, cx| thread.compact(client_user_message_id, cx))?;
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.update_token_usage(None, cx);
|
|
});
|
|
|
|
let connection = this.upgrade().map(NativeAgentConnection);
|
|
cx.update(|cx| {
|
|
NativeAgentConnection::handle_thread_events(
|
|
response_stream,
|
|
acp_thread.downgrade(),
|
|
connection,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
})
|
|
}
|
|
|
|
/// Activate a skill in response to a `/skill-name` slash command. The
|
|
/// skill body is wrapped in the same `<skill_content>` envelope the
|
|
/// model-driven `skill` tool uses, so the conversation looks the same
|
|
/// regardless of who initiated the load. Any text the user typed after
|
|
/// the command on the same line — plus any additional content blocks
|
|
/// they attached (file mentions, etc.) — is appended to the same user
|
|
/// message after the skill envelope, so the model sees the skill
|
|
/// instructions followed by the user's request.
|
|
fn send_skill_invocation(
|
|
&self,
|
|
client_user_message_id: ClientUserMessageId,
|
|
session_id: acp_v2::SessionId,
|
|
skill: Skill,
|
|
original_content: Vec<acp_v1::ContentBlock>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
let Some(state) = self.session_project_state(&session_id) else {
|
|
return Task::ready(Err(anyhow!("Project state not found for session")));
|
|
};
|
|
let path_style = state.project.read(cx).path_style(cx);
|
|
let read_skill_body =
|
|
skill_body_resolver_for_project(state.project.clone(), self.fs.clone());
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let (acp_thread, thread) = this.update(cx, |this, _cx| {
|
|
let session = this
|
|
.sessions
|
|
.get(&session_id)
|
|
.context("Failed to get session")?;
|
|
let acp_thread = session
|
|
.acp_thread
|
|
.upgrade()
|
|
.context("Session was released")?;
|
|
anyhow::Ok((acp_thread, session.thread.clone()))
|
|
})??;
|
|
|
|
// Build the model-context message: skill envelope first, then
|
|
// anything the user wrote after the slash command. The first
|
|
// text block has its leading `/cmd` stripped so the literal
|
|
// command name isn't echoed into the model's context, but any
|
|
// text the user typed after it on the same line is preserved
|
|
// verbatim and appended after the envelope.
|
|
//
|
|
// Read the body on demand here — bodies live on disk between
|
|
// materializations to keep memory cost O(total frontmatter)
|
|
// rather than O(total file size).
|
|
let body = if let Some(embedded) = skill.embedded_body {
|
|
embedded.to_string()
|
|
} else {
|
|
read_skill_body(skill.clone(), cx).await.with_context(|| {
|
|
format!(
|
|
"Failed to read skill body from {}",
|
|
skill.skill_file_path.display()
|
|
)
|
|
})?
|
|
};
|
|
let envelope = crate::tools::render_skill_envelope(&skill, &body);
|
|
let envelope_block = acp_v1::ContentBlock::Text(acp_v1::TextContent::new(envelope));
|
|
|
|
let mut user_blocks = original_content;
|
|
if let Some(acp_v1::ContentBlock::Text(text_content)) = user_blocks.first_mut() {
|
|
let stripped = strip_slash_command_prefix(&text_content.text);
|
|
if stripped.trim().is_empty() {
|
|
user_blocks.remove(0);
|
|
} else {
|
|
text_content.text = stripped;
|
|
}
|
|
}
|
|
|
|
// UI: show the rendered envelope as a sibling user message so
|
|
// the user can see what context was loaded for the skill. The
|
|
// user's own typed message is already rendered by the normal
|
|
// prompt flow, so we don't push it to the UI again here.
|
|
let injected_id = acp_thread::ClientUserMessageId::new();
|
|
let display_block = acp_thread::content::from_v1(envelope_block.clone())?;
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.push_user_content_block_with_indent(
|
|
Some(injected_id),
|
|
display_block,
|
|
true,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
// Model context: a single user message containing the skill
|
|
// envelope followed by the user's appended content.
|
|
let mut combined = Vec::with_capacity(user_blocks.len() + 1);
|
|
combined.push(envelope_block);
|
|
combined.extend(user_blocks);
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.push_acp_user_block(client_user_message_id, combined, path_style, cx);
|
|
});
|
|
|
|
let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?;
|
|
|
|
let connection = this.upgrade().map(NativeAgentConnection);
|
|
cx.update(|cx| {
|
|
NativeAgentConnection::handle_thread_events(
|
|
response_stream,
|
|
acp_thread.downgrade(),
|
|
connection,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Wrapper struct that implements the AgentConnection trait
|
|
#[derive(Clone)]
|
|
pub struct NativeAgentConnection(pub Entity<NativeAgent>);
|
|
|
|
impl NativeAgentConnection {
|
|
pub fn thread(&self, session_id: &acp_v2::SessionId, cx: &App) -> Option<Entity<Thread>> {
|
|
self.0
|
|
.read(cx)
|
|
.sessions
|
|
.get(session_id)
|
|
.map(|session| session.thread.clone())
|
|
}
|
|
|
|
/// Forwards to [`NativeAgent::ensure_skills_scan_started`]. The
|
|
/// agent panel calls this from its three user-interaction trigger
|
|
/// points (input box focus, slash-autocomplete invocation, and
|
|
/// conversation submit) so that the skills directory is observed
|
|
/// only when the user is actually engaging with the panel.
|
|
pub fn ensure_skills_scan_started(&self, cx: &mut App) {
|
|
self.0
|
|
.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
}
|
|
|
|
pub fn refresh_skills_for_project(&self, project: Entity<Project>, cx: &mut App) {
|
|
self.0.update(cx, |agent, cx| {
|
|
let project_id = agent.get_or_create_project_state(&project, cx);
|
|
agent.ensure_skills_scan_started(cx);
|
|
if let Some(state) = agent.projects.get_mut(&project_id) {
|
|
state.project_context_needs_refresh.send(()).ok();
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn available_skills(
|
|
&self,
|
|
session_id: &acp_v2::SessionId,
|
|
cx: &App,
|
|
) -> Vec<NativeAvailableSkill> {
|
|
self.0
|
|
.read(cx)
|
|
.session_project_state(session_id)
|
|
.map(|state| {
|
|
state
|
|
.skills
|
|
.iter()
|
|
.map(NativeAvailableSkill::from)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn load_thread(
|
|
&self,
|
|
id: acp_v2::SessionId,
|
|
project: Entity<Project>,
|
|
cx: &mut App,
|
|
) -> Task<Result<Entity<Thread>>> {
|
|
self.0
|
|
.update(cx, |this, cx| this.load_thread(id, project, cx))
|
|
}
|
|
|
|
fn run_turn(
|
|
&self,
|
|
session_id: acp_v2::SessionId,
|
|
cx: &mut App,
|
|
f: impl 'static
|
|
+ FnOnce(Entity<Thread>, &mut App) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| {
|
|
let session = agent.sessions.get(&session_id)?;
|
|
Some((session.thread.clone(), session.acp_thread.clone()))
|
|
}) else {
|
|
log::error!("Session not found in run_turn: {}", session_id);
|
|
return Task::ready(Err(anyhow!("Session not found")));
|
|
};
|
|
log::debug!("Found session for: {}", session_id);
|
|
|
|
let response_stream = match f(thread, cx) {
|
|
Ok(stream) => stream,
|
|
Err(err) => return Task::ready(Err(err)),
|
|
};
|
|
Self::handle_thread_events(response_stream, acp_thread, Some(self.clone()), cx)
|
|
}
|
|
|
|
fn handle_thread_events(
|
|
mut events: mpsc::UnboundedReceiver<Result<ThreadEvent>>,
|
|
acp_thread: WeakEntity<AcpThread>,
|
|
connection: Option<NativeAgentConnection>,
|
|
cx: &App,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
cx.spawn(async move |cx| {
|
|
// Handle response stream and forward to session.acp_thread
|
|
while let Some(result) = events.next().await {
|
|
match result {
|
|
Ok(event) => {
|
|
log::trace!("Received completion event: {:?}", event);
|
|
|
|
match event {
|
|
ThreadEvent::UserMessage(message) => {
|
|
let content = message.content.iter().cloned()
|
|
.map(|content| acp_thread::content::from_v1(content.into()))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
acp_thread.update(cx, |thread, cx| {
|
|
for content in content {
|
|
thread.push_user_content_block(
|
|
Some(message.id.clone()),
|
|
content,
|
|
cx,
|
|
);
|
|
}
|
|
})?;
|
|
}
|
|
ThreadEvent::AgentText(text) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.push_assistant_content_block(text.into(), false, cx)
|
|
})?;
|
|
}
|
|
ThreadEvent::AgentThinking(text) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.push_assistant_content_block(text.into(), true, cx)
|
|
})?;
|
|
}
|
|
ThreadEvent::ToolCallAuthorization(ToolCallAuthorization {
|
|
tool_call,
|
|
options,
|
|
response,
|
|
context: _,
|
|
kind,
|
|
}) => {
|
|
let outcome_task = acp_thread.update(cx, |thread, cx| {
|
|
thread.request_tool_call_update_authorization(
|
|
tool_call, options, kind, cx,
|
|
)
|
|
})??;
|
|
cx.background_spawn(async move {
|
|
let outcome = match outcome_task.await {
|
|
acp_thread::RequestPermissionOutcome::Selected(outcome) => outcome,
|
|
acp_thread::RequestPermissionOutcome::InterruptedByFollowUp => {
|
|
acp_thread::SelectedPermissionOutcome::new(
|
|
acp_v2::PermissionOptionId::new(
|
|
FOLLOW_UP_PERMISSION_DENIED_OPTION_ID,
|
|
),
|
|
acp_v2::PermissionOptionKind::RejectOnce,
|
|
)
|
|
}
|
|
acp_thread::RequestPermissionOutcome::Cancelled => return,
|
|
};
|
|
response
|
|
.send(outcome)
|
|
.map_err(|_| anyhow!("authorization receiver was dropped"))
|
|
.log_err();
|
|
})
|
|
.detach();
|
|
}
|
|
ThreadEvent::ToolCallAuthorizationResolved {
|
|
tool_call_id,
|
|
outcome,
|
|
} => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.authorize_tool_call(tool_call_id, outcome, cx);
|
|
})?;
|
|
}
|
|
ThreadEvent::Elicitation(ElicitationRequest {
|
|
tool_call_id,
|
|
message,
|
|
schema,
|
|
response,
|
|
}) => {
|
|
let request_result = acp_thread.update(cx, |thread, cx| {
|
|
let scope = acp_v2::ElicitationSessionScope::new(
|
|
thread.session_id().clone(),
|
|
)
|
|
.tool_call_id(tool_call_id);
|
|
let request = acp_v2::CreateElicitationRequest::new(
|
|
acp_v2::ElicitationFormMode::new(scope, schema),
|
|
message,
|
|
);
|
|
thread.request_elicitation(request, cx)
|
|
})?;
|
|
match request_result {
|
|
Ok(response_task) => {
|
|
cx.background_spawn(async move {
|
|
let elicitation_response = response_task.await;
|
|
response
|
|
.send(elicitation_response)
|
|
.map_err(|_| {
|
|
anyhow!("elicitation receiver was dropped")
|
|
})
|
|
.log_err();
|
|
})
|
|
.detach();
|
|
}
|
|
Err(error) => {
|
|
log::error!("Failed to request elicitation: {error:?}");
|
|
// Resolve the tool's pending request so it
|
|
// doesn't hang waiting on a form that will
|
|
// never render.
|
|
response
|
|
.send(acp_v2::CreateElicitationResponse::new(
|
|
acp_v2::ElicitationAction::Cancel,
|
|
))
|
|
.ok();
|
|
}
|
|
}
|
|
}
|
|
ThreadEvent::ToolCallUpdate(update) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.update_tool_call(update, cx)
|
|
})??;
|
|
}
|
|
ThreadEvent::SubagentSpawned(session_id) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.subagent_spawned(session_id, cx);
|
|
})?;
|
|
}
|
|
ThreadEvent::Retry(status) => {
|
|
if acp_thread::refusal_fallback_model_from_meta(&status.meta)
|
|
.is_some()
|
|
{
|
|
if let Some(connection) = &connection {
|
|
cx.update(|cx| {
|
|
connection.0.update(cx, |agent, _| {
|
|
agent.models.notify_model_selection_changed();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.update_retry_status(status, cx)
|
|
})?;
|
|
}
|
|
ThreadEvent::ContextCompaction(compaction) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.push_context_compaction(compaction, cx);
|
|
})?;
|
|
}
|
|
ThreadEvent::ContextCompactionUpdate(update) => {
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.update_context_compaction(update, cx);
|
|
})?;
|
|
}
|
|
ThreadEvent::Stop(stop_reason) => {
|
|
log::debug!("Assistant message complete: {:?}", stop_reason);
|
|
return legacy_native_completion(stop_reason);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("Error in model response stream: {:?}", e);
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
log::debug!("Response stream completed");
|
|
anyhow::Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn legacy_native_completion(stop_reason: acp_v2::StopReason) -> Result<acp_v1::PromptResponse> {
|
|
// Native events are version-neutral, but the completion-based connection API
|
|
// still returns a v1 response. This is not a v2 acceptance receipt.
|
|
let stop_reason = match stop_reason {
|
|
acp_v2::StopReason::EndTurn => acp_v1::StopReason::EndTurn,
|
|
acp_v2::StopReason::MaxTokens => acp_v1::StopReason::MaxTokens,
|
|
acp_v2::StopReason::MaxTurnRequests => acp_v1::StopReason::MaxTurnRequests,
|
|
acp_v2::StopReason::Refusal => acp_v1::StopReason::Refusal,
|
|
acp_v2::StopReason::Cancelled => acp_v1::StopReason::Cancelled,
|
|
acp_v2::StopReason::Error(reason) => {
|
|
return Err(match reason.error {
|
|
Some(error) => anyhow::Error::new(*error),
|
|
None => anyhow!("Native agent stopped with an error"),
|
|
});
|
|
}
|
|
_ => anyhow::bail!("Native agent returned an unsupported stop reason"),
|
|
};
|
|
Ok(acp_v1::PromptResponse::new(stop_reason))
|
|
}
|
|
|
|
struct Command<'a> {
|
|
prompt_name: &'a str,
|
|
arg_value: &'a str,
|
|
/// MCP server prefix from `/<server>.<prompt>` syntax. Mutually
|
|
/// exclusive with `skill_scope` — the two grammars use different
|
|
/// delimiters (`.` for MCP, `:` for skill scopes) so they can't
|
|
/// collide.
|
|
explicit_server_id: Option<&'a str>,
|
|
/// Skill scope qualifier from `/<scope>:<name>` syntax, where
|
|
/// `<scope>` is either the literal `global` or a worktree root
|
|
/// name. The `:` separator namespaces these against MCP server
|
|
/// prefixes (which use `.`) so an MCP server literally named
|
|
/// `global` or named after a worktree still parses unambiguously.
|
|
skill_scope: Option<&'a str>,
|
|
}
|
|
|
|
impl<'a> Command<'a> {
|
|
fn is_unqualified(&self, prompt_name: &str) -> bool {
|
|
self.prompt_name == prompt_name
|
|
&& self.explicit_server_id.is_none()
|
|
&& self.skill_scope.is_none()
|
|
}
|
|
|
|
fn parse(prompt: &'a [acp_v1::ContentBlock]) -> Option<Self> {
|
|
let acp_v1::ContentBlock::Text(text_content) = prompt.first()? else {
|
|
return None;
|
|
};
|
|
let text = text_content.text.trim();
|
|
let command = text.strip_prefix('/')?;
|
|
let (command, arg_value) = command
|
|
.split_once(char::is_whitespace)
|
|
.unwrap_or((command, ""));
|
|
|
|
// Skill scope qualifier: `/<scope>:<name>`. Checked before the
|
|
// MCP `.` grammar because `:` and `.` are different delimiters
|
|
// — the two namespaces can't collide. Skill names are
|
|
// restricted to `[a-z0-9-]+` (no colons), so the LAST `:` is
|
|
// always the scope/name boundary; using `rsplit_once` lets
|
|
// scope labels (e.g. a worktree root name) themselves contain
|
|
// colons without breaking the parse.
|
|
//
|
|
// An empty scope (`/:<name>`) is the qualified form for a
|
|
// global skill — see `SkillSource::scope_prefix`. The name
|
|
// must be non-empty for the colon to be meaningful.
|
|
if let Some((scope, prompt_name)) = command.rsplit_once(':')
|
|
&& !prompt_name.is_empty()
|
|
{
|
|
return Some(Self {
|
|
prompt_name,
|
|
arg_value,
|
|
explicit_server_id: None,
|
|
skill_scope: Some(scope),
|
|
});
|
|
}
|
|
|
|
if let Some((server_id, prompt_name)) = command.split_once('.') {
|
|
Some(Self {
|
|
prompt_name,
|
|
arg_value,
|
|
explicit_server_id: Some(server_id),
|
|
skill_scope: None,
|
|
})
|
|
} else {
|
|
Some(Self {
|
|
prompt_name: command,
|
|
arg_value,
|
|
explicit_server_id: None,
|
|
skill_scope: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Strip a leading `/cmd` slash command from the start of a text block,
|
|
/// returning whatever text comes after it. Mirrors the parsing in
|
|
/// [`Command::parse`]: leading whitespace is ignored when locating the `/`,
|
|
/// then everything up to (and including) the first whitespace inside the
|
|
/// stripped text is dropped. The remainder is preserved verbatim — including
|
|
/// any embedded newlines — because users may format their continuation
|
|
/// intentionally.
|
|
///
|
|
/// If the input doesn't begin with `/`, it is returned unchanged so callers
|
|
/// degrade gracefully rather than silently mangling unrelated text.
|
|
fn strip_slash_command_prefix(text: &str) -> String {
|
|
let trimmed_start = text.trim_start();
|
|
let Some(rest) = trimmed_start.strip_prefix('/') else {
|
|
return text.to_string();
|
|
};
|
|
rest.split_once(char::is_whitespace)
|
|
.map(|(_, after)| after.to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
struct NativeAgentModelSelector {
|
|
session_id: acp_v2::SessionId,
|
|
connection: NativeAgentConnection,
|
|
}
|
|
|
|
impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
|
|
fn list_models(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelList>> {
|
|
log::debug!("NativeAgentConnection::list_models called");
|
|
let list = self.connection.0.read(cx).models.model_list.clone();
|
|
Task::ready(if list.is_empty() {
|
|
Err(anyhow::anyhow!("No models available"))
|
|
} else {
|
|
Ok(list)
|
|
})
|
|
}
|
|
|
|
fn select_model(&self, model_id: AgentModelId, cx: &mut App) -> Task<Result<()>> {
|
|
log::debug!(
|
|
"Setting model for session {}: {}",
|
|
self.session_id,
|
|
model_id
|
|
);
|
|
let Some(thread) = self
|
|
.connection
|
|
.0
|
|
.read(cx)
|
|
.sessions
|
|
.get(&self.session_id)
|
|
.map(|session| session.thread.clone())
|
|
else {
|
|
return Task::ready(Err(anyhow!("Session not found")));
|
|
};
|
|
|
|
let Some(model) = self.connection.0.read(cx).models.model_from_id(&model_id) else {
|
|
return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
|
|
};
|
|
|
|
let favorite = agent_settings::AgentSettings::get_global(cx)
|
|
.favorite_models
|
|
.iter()
|
|
.find(|favorite| {
|
|
favorite.provider.0 == model.provider_id().0.as_ref()
|
|
&& favorite.model == model.id().0.as_ref()
|
|
})
|
|
.cloned();
|
|
|
|
let LanguageModelSelection {
|
|
enable_thinking,
|
|
effort,
|
|
speed,
|
|
..
|
|
} = agent_settings::language_model_to_selection(&model, favorite.as_ref());
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread.set_thinking_effort(effort.clone(), cx);
|
|
thread.set_thinking_enabled(enable_thinking, cx);
|
|
if let Some(speed) = speed {
|
|
thread.set_speed(speed, cx);
|
|
}
|
|
});
|
|
|
|
update_settings_file(
|
|
self.connection.0.read(cx).fs.clone(),
|
|
cx,
|
|
move |settings, cx| {
|
|
let provider = model.provider_id().0.to_string();
|
|
let model = model.id().0.to_string();
|
|
let enable_thinking = thread.read(cx).thinking_enabled();
|
|
let speed = thread.read(cx).speed();
|
|
settings
|
|
.agent
|
|
.get_or_insert_default()
|
|
.set_model(LanguageModelSelection {
|
|
provider: provider.into(),
|
|
model,
|
|
enable_thinking,
|
|
effort,
|
|
speed,
|
|
});
|
|
},
|
|
);
|
|
|
|
Task::ready(Ok(()))
|
|
}
|
|
|
|
fn selected_model(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelInfo>> {
|
|
let Some(thread) = self
|
|
.connection
|
|
.0
|
|
.read(cx)
|
|
.sessions
|
|
.get(&self.session_id)
|
|
.map(|session| session.thread.clone())
|
|
else {
|
|
return Task::ready(Err(anyhow!("Session not found")));
|
|
};
|
|
let Some(model) = thread.read(cx).model() else {
|
|
return Task::ready(Err(anyhow!("Model not found")));
|
|
};
|
|
let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id())
|
|
else {
|
|
return Task::ready(Err(anyhow!("Provider not found")));
|
|
};
|
|
Task::ready(Ok(LanguageModels::map_language_model_to_info(
|
|
model, &provider,
|
|
)))
|
|
}
|
|
|
|
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<AgentModelId> {
|
|
agent_settings::AgentSettings::get_global(cx)
|
|
.favorite_model_ids()
|
|
.into_iter()
|
|
.map(AgentModelId::from)
|
|
.collect()
|
|
}
|
|
|
|
fn toggle_favorite_model(&self, model_id: AgentModelId, should_be_favorite: bool, cx: &App) {
|
|
let selection = model_id_to_selection(&model_id, cx);
|
|
let fs = self.connection.0.read(cx).fs.clone();
|
|
update_settings_file(fs, cx, move |settings, _| {
|
|
let agent = settings.agent.get_or_insert_default();
|
|
if should_be_favorite {
|
|
agent.add_favorite_model(selection.clone());
|
|
} else {
|
|
agent.remove_favorite_model(&selection);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn watch(&self, cx: &mut App) -> Option<watch::Receiver<()>> {
|
|
Some(self.connection.0.read(cx).models.watch())
|
|
}
|
|
|
|
fn should_render_footer(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn subagent_model_id_to_selection(model_id: &AgentModelId, cx: &App) -> LanguageModelSelection {
|
|
let settings = agent_settings::AgentSettings::get_global(cx);
|
|
let Some((provider, model)) = model_id.as_ref().split_once('/') else {
|
|
return model_id_to_selection(model_id, cx);
|
|
};
|
|
if let Some(selection) = settings.subagent_model.as_ref()
|
|
&& selection.provider.0 == provider
|
|
&& selection.model == model
|
|
{
|
|
return selection.clone();
|
|
}
|
|
model_id_to_selection(model_id, cx)
|
|
}
|
|
|
|
fn model_id_to_selection(model_id: &AgentModelId, cx: &App) -> LanguageModelSelection {
|
|
let id = model_id.as_ref();
|
|
let (provider, model) = id.split_once('/').unwrap_or(("", id));
|
|
|
|
let provider_id = LanguageModelProviderId(provider.to_string().into());
|
|
let model_id = LanguageModelId(model.to_string().into());
|
|
let resolved = LanguageModelRegistry::global(cx)
|
|
.read(cx)
|
|
.provider(&provider_id)
|
|
.and_then(|provider| {
|
|
provider
|
|
.provided_models(cx)
|
|
.into_iter()
|
|
.find(|model| model.id() == model_id)
|
|
});
|
|
|
|
let Some(resolved) = resolved else {
|
|
return LanguageModelSelection {
|
|
provider: provider.to_owned().into(),
|
|
model: model.to_owned(),
|
|
enable_thinking: false,
|
|
effort: None,
|
|
speed: None,
|
|
};
|
|
};
|
|
|
|
let current_user_selection = agent_settings::AgentSettings::get_global(cx)
|
|
.default_model
|
|
.as_ref()
|
|
.filter(|selection| {
|
|
selection.provider.0 == resolved.provider_id().0.as_ref()
|
|
&& selection.model == resolved.id().0.as_ref()
|
|
})
|
|
.cloned();
|
|
|
|
agent_settings::language_model_to_selection(&resolved, current_user_selection.as_ref())
|
|
}
|
|
|
|
pub static ZED_AGENT_ID: LazyLock<AgentId> = LazyLock::new(|| AgentId::new("Zed Agent"));
|
|
|
|
pub fn available_native_agent(cx: &App) -> AvailableAgent {
|
|
let registry = LanguageModelRegistry::read_global(cx);
|
|
let default = registry.default_model();
|
|
let mut models = Vec::new();
|
|
for provider in registry.visible_providers() {
|
|
if !provider.is_authenticated(cx) {
|
|
continue;
|
|
}
|
|
let provider_id = provider.id();
|
|
for model in provider.provided_models(cx) {
|
|
let id = format!("{}/{}", provider_id.0, model.id().0);
|
|
let is_default = default.as_ref().is_some_and(|default| {
|
|
default.provider_id == provider_id && default.id == model.id
|
|
});
|
|
models.push(AvailableModel {
|
|
id,
|
|
name: model.name().0,
|
|
is_default,
|
|
});
|
|
}
|
|
}
|
|
|
|
AvailableAgent {
|
|
id: ZED_AGENT_ID.to_string(),
|
|
name: ZED_AGENT_ID.0.clone(),
|
|
is_native: true,
|
|
models,
|
|
}
|
|
}
|
|
|
|
impl acp_thread::AgentConnection for NativeAgentConnection {
|
|
fn agent_id(&self) -> AgentId {
|
|
ZED_AGENT_ID.clone()
|
|
}
|
|
|
|
fn telemetry_id(&self) -> SharedString {
|
|
"zed".into()
|
|
}
|
|
|
|
fn new_session(
|
|
self: Rc<Self>,
|
|
project: Entity<Project>,
|
|
work_dirs: PathList,
|
|
cx: &mut App,
|
|
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
|
log::debug!("Creating new thread for project at: {work_dirs:?}");
|
|
Task::ready(Ok(self
|
|
.0
|
|
.update(cx, |agent, cx| agent.new_session(project, cx))))
|
|
}
|
|
|
|
fn supports_load_session(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn load_session(
|
|
self: Rc<Self>,
|
|
session_id: acp_v2::SessionId,
|
|
project: Entity<Project>,
|
|
_work_dirs: PathList,
|
|
_title: Option<SharedString>,
|
|
cx: &mut App,
|
|
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
|
self.0
|
|
.update(cx, |agent, cx| agent.open_thread(session_id, project, cx))
|
|
}
|
|
|
|
fn auth_methods(&self) -> &[acp_v2::AuthMethod] {
|
|
&[] // No auth for in-process
|
|
}
|
|
|
|
fn authenticate(&self, _method: acp_v2::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
|
|
Task::ready(Ok(()))
|
|
}
|
|
|
|
fn model_selector(&self, session_id: &acp_v2::SessionId) -> Option<Rc<dyn AgentModelSelector>> {
|
|
Some(Rc::new(NativeAgentModelSelector {
|
|
session_id: session_id.clone(),
|
|
connection: self.clone(),
|
|
}) as Rc<dyn AgentModelSelector>)
|
|
}
|
|
|
|
fn client_user_message_ids(
|
|
&self,
|
|
_cx: &App,
|
|
) -> Option<Rc<dyn acp_thread::AgentSessionClientUserMessageIds>> {
|
|
let prompt: Rc<dyn acp_thread::AgentSessionClientUserMessageIds> = Rc::new(self.clone());
|
|
Some(prompt)
|
|
}
|
|
|
|
fn validate_prompt_content(&self, content: &[acp_v2::ContentBlock]) -> Result<()> {
|
|
acp_thread::content::validate_prompt_content_for_v1(content)
|
|
}
|
|
|
|
fn prompt(
|
|
&self,
|
|
params: acp_v2::PromptRequest,
|
|
cx: &mut App,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
acp_thread::AgentSessionClientUserMessageIds::prompt(
|
|
self,
|
|
acp_thread::AgentSessionClientUserMessageIds::new_id(self),
|
|
params,
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn retry(
|
|
&self,
|
|
session_id: &acp_v2::SessionId,
|
|
_cx: &App,
|
|
) -> Option<Rc<dyn acp_thread::AgentSessionRetry>> {
|
|
Some(Rc::new(NativeAgentSessionRetry {
|
|
connection: self.clone(),
|
|
session_id: session_id.clone(),
|
|
}) as _)
|
|
}
|
|
|
|
fn cancel(&self, session_id: &acp_v2::SessionId, cx: &mut App) {
|
|
log::info!("Cancelling on session: {}", session_id);
|
|
self.0.update(cx, |agent, cx| {
|
|
if let Some(session) = agent.sessions.get(session_id) {
|
|
session
|
|
.thread
|
|
.update(cx, |thread, cx| thread.cancel(cx))
|
|
.detach();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn truncate(
|
|
&self,
|
|
session_id: &acp_v2::SessionId,
|
|
cx: &App,
|
|
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
|
|
self.0.read_with(cx, |agent, _cx| {
|
|
agent.sessions.get(session_id).map(|session| {
|
|
Rc::new(NativeAgentSessionTruncate {
|
|
thread: session.thread.clone(),
|
|
acp_thread: session.acp_thread.clone(),
|
|
}) as _
|
|
})
|
|
})
|
|
}
|
|
|
|
fn set_title(
|
|
&self,
|
|
session_id: &acp_v2::SessionId,
|
|
cx: &App,
|
|
) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> {
|
|
self.0.read_with(cx, |agent, _cx| {
|
|
agent
|
|
.sessions
|
|
.get(session_id)
|
|
.filter(|s| !s.thread.read(cx).is_subagent())
|
|
.map(|session| {
|
|
Rc::new(NativeAgentSessionSetTitle {
|
|
thread: session.thread.clone(),
|
|
}) as _
|
|
})
|
|
})
|
|
}
|
|
|
|
fn session_list(&self, cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
|
|
let thread_store = self.0.read(cx).thread_store.clone();
|
|
Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _)
|
|
}
|
|
|
|
fn telemetry(&self) -> Option<Rc<dyn acp_thread::AgentTelemetry>> {
|
|
Some(Rc::new(self.clone()) as Rc<dyn acp_thread::AgentTelemetry>)
|
|
}
|
|
|
|
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
|
self
|
|
}
|
|
}
|
|
|
|
impl acp_thread::AgentSessionClientUserMessageIds for NativeAgentConnection {
|
|
fn prompt(
|
|
&self,
|
|
client_user_message_id: acp_thread::ClientUserMessageId,
|
|
params: acp_v2::PromptRequest,
|
|
cx: &mut App,
|
|
) -> Task<Result<acp_v1::PromptResponse>> {
|
|
let session_id = params.session_id.clone();
|
|
let params = match acp_thread::content::prompt_to_v1(params) {
|
|
Ok(params) => params,
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
log::info!("Received prompt request for session: {}", session_id);
|
|
log::debug!("Prompt blocks count: {}", params.prompt.len());
|
|
|
|
let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else {
|
|
log::error!("Session not found in prompt: {}", session_id);
|
|
if self.0.read(cx).sessions.contains_key(&session_id) {
|
|
log::error!(
|
|
"Session found in sessions map, but not in project state: {}",
|
|
session_id
|
|
);
|
|
}
|
|
return Task::ready(Err(anyhow::anyhow!("Session not found")));
|
|
};
|
|
|
|
if let Some(parsed_command) = Command::parse(¶ms.prompt) {
|
|
if parsed_command.is_unqualified(COMPACT_COMMAND_NAME) {
|
|
return self.0.update(cx, |agent, cx| {
|
|
agent.send_compact_command(client_user_message_id, session_id, cx)
|
|
});
|
|
}
|
|
|
|
// Skill scope qualifiers (`/:<name>` and
|
|
// `/<worktree>:<name>`) use a colon separator that can't
|
|
// collide with MCP's `/<server>.<name>` grammar. The popup
|
|
// inserts a qualified form for every skill so picking the
|
|
// global row unambiguously runs the global skill even when
|
|
// a same-named project-local one exists.
|
|
if let Some(scope) = parsed_command.skill_scope
|
|
&& let Some(skill) = project_state.skills.iter().find(|skill| {
|
|
skill.name == parsed_command.prompt_name && skill.source.matches_scope(scope)
|
|
})
|
|
{
|
|
let skill = skill.clone();
|
|
return self.0.update(cx, |agent, cx| {
|
|
agent.send_skill_invocation(
|
|
client_user_message_id,
|
|
session_id.clone(),
|
|
skill,
|
|
params.prompt,
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
|
|
// MCP prompts and skills both register slash commands. MCP
|
|
// prompts are checked first — if a user has both an MCP prompt
|
|
// and a skill with the same name, the MCP prompt wins (matching
|
|
// the order they appear in the catalog).
|
|
let registry = project_state.context_server_registry.read(cx);
|
|
|
|
let explicit_server_id = parsed_command
|
|
.explicit_server_id
|
|
.map(|server_id| ContextServerId(server_id.into()));
|
|
|
|
if let Some(prompt) =
|
|
registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
|
|
{
|
|
let arguments = if !parsed_command.arg_value.is_empty()
|
|
&& let Some(arg_name) = prompt
|
|
.prompt
|
|
.arguments
|
|
.as_ref()
|
|
.and_then(|args| args.first())
|
|
.map(|arg| arg.name.clone())
|
|
{
|
|
HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
|
|
} else {
|
|
Default::default()
|
|
};
|
|
|
|
let prompt_name = prompt.prompt.name.clone();
|
|
let server_id = prompt.server_id.clone();
|
|
|
|
return self.0.update(cx, |agent, cx| {
|
|
agent.send_mcp_prompt(
|
|
client_user_message_id,
|
|
session_id.clone(),
|
|
prompt_name,
|
|
server_id,
|
|
arguments,
|
|
params.prompt,
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
|
|
// Unqualified skill match (`/skill-name` with no scope
|
|
// prefix and no MCP server prefix). Slash commands work
|
|
// for *all* skills regardless of `disable_model_invocation`
|
|
// — that flag only hides the skill from the model's catalog.
|
|
// The user explicitly typed the name, so they get to invoke
|
|
// it.
|
|
//
|
|
// Inlined rather than calling `apply_skill_overrides` so
|
|
// we don't clone the entire skill list on every prompt
|
|
// (including prompts like `/help` that aren't skills at
|
|
// all). The resolution rule matches the override-applied
|
|
// view: among skills with the matching name, pick the one
|
|
// with the highest source precedence, so the slash command
|
|
// picks the same entry the model sees in its catalog.
|
|
// Ties (e.g. two project-local skills from different
|
|
// worktrees) resolve to the first in iteration order to
|
|
// match `apply_skill_overrides`.
|
|
if parsed_command.explicit_server_id.is_none()
|
|
&& parsed_command.skill_scope.is_none()
|
|
&& !project_state.skills.is_empty()
|
|
{
|
|
let prompt_name = parsed_command.prompt_name;
|
|
let resolved = project_state
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| skill.name == prompt_name)
|
|
.reduce(|best, candidate| {
|
|
if candidate.source.precedence() > best.source.precedence() {
|
|
candidate
|
|
} else {
|
|
best
|
|
}
|
|
});
|
|
if let Some(skill) = resolved {
|
|
let skill = skill.clone();
|
|
return self.0.update(cx, |agent, cx| {
|
|
agent.send_skill_invocation(
|
|
client_user_message_id,
|
|
session_id.clone(),
|
|
skill,
|
|
params.prompt,
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
let path_style = project_state.project.read(cx).path_style(cx);
|
|
|
|
self.run_turn(session_id, cx, move |thread, cx| {
|
|
let content: Vec<UserMessageContent> = params
|
|
.prompt
|
|
.into_iter()
|
|
.map(|block| UserMessageContent::from_content_block(block, path_style))
|
|
.collect::<Vec<_>>();
|
|
log::debug!("Converted prompt to message: {} chars", content.len());
|
|
log::debug!("Client user message id: {:?}", client_user_message_id);
|
|
log::debug!("Message content: {:?}", content);
|
|
|
|
thread.update(cx, |thread, cx| {
|
|
thread.send(client_user_message_id, content, cx)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl acp_thread::AgentTelemetry for NativeAgentConnection {
|
|
fn thread_data(
|
|
&self,
|
|
session_id: &acp_v2::SessionId,
|
|
cx: &mut App,
|
|
) -> Task<Result<serde_json::Value>> {
|
|
let Some(session) = self.0.read(cx).sessions.get(session_id) else {
|
|
return Task::ready(Err(anyhow!("Session not found")));
|
|
};
|
|
|
|
let task = session.thread.read(cx).to_db(cx);
|
|
cx.background_spawn(async move {
|
|
serde_json::to_value(task.await).context("Failed to serialize thread")
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct NativeAgentSessionList {
|
|
thread_store: Entity<ThreadStore>,
|
|
updates_tx: async_channel::Sender<acp_thread::SessionListUpdate>,
|
|
updates_rx: async_channel::Receiver<acp_thread::SessionListUpdate>,
|
|
_subscription: Subscription,
|
|
}
|
|
|
|
impl NativeAgentSessionList {
|
|
fn new(thread_store: Entity<ThreadStore>, cx: &mut App) -> Self {
|
|
let (tx, rx) = async_channel::unbounded();
|
|
let this_tx = tx.clone();
|
|
let subscription = cx.observe(&thread_store, move |_, _| {
|
|
this_tx
|
|
.try_send(acp_thread::SessionListUpdate::Refresh)
|
|
.ok();
|
|
});
|
|
Self {
|
|
thread_store,
|
|
updates_tx: tx,
|
|
updates_rx: rx,
|
|
_subscription: subscription,
|
|
}
|
|
}
|
|
|
|
pub fn thread_store(&self) -> &Entity<ThreadStore> {
|
|
&self.thread_store
|
|
}
|
|
}
|
|
|
|
impl AgentSessionList for NativeAgentSessionList {
|
|
fn list_sessions(
|
|
&self,
|
|
_request: AgentSessionListRequest,
|
|
cx: &mut App,
|
|
) -> Task<Result<AgentSessionListResponse>> {
|
|
let sessions = self
|
|
.thread_store
|
|
.read(cx)
|
|
.entries()
|
|
.map(|entry| AgentSessionInfo::from(&entry))
|
|
.collect();
|
|
Task::ready(Ok(AgentSessionListResponse::new(sessions)))
|
|
}
|
|
|
|
fn supports_delete(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn delete_session(&self, session_id: &acp_v2::SessionId, cx: &mut App) -> Task<Result<()>> {
|
|
self.thread_store
|
|
.update(cx, |store, cx| store.delete_thread(session_id.clone(), cx))
|
|
}
|
|
|
|
fn delete_sessions(&self, cx: &mut App) -> Task<Result<()>> {
|
|
self.thread_store
|
|
.update(cx, |store, cx| store.delete_threads(cx))
|
|
}
|
|
|
|
fn watch(
|
|
&self,
|
|
_cx: &mut App,
|
|
) -> Option<async_channel::Receiver<acp_thread::SessionListUpdate>> {
|
|
Some(self.updates_rx.clone())
|
|
}
|
|
|
|
fn notify_refresh(&self) {
|
|
self.updates_tx
|
|
.try_send(acp_thread::SessionListUpdate::Refresh)
|
|
.ok();
|
|
}
|
|
|
|
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
|
self
|
|
}
|
|
}
|
|
|
|
struct NativeAgentSessionTruncate {
|
|
thread: Entity<Thread>,
|
|
acp_thread: WeakEntity<AcpThread>,
|
|
}
|
|
|
|
impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
|
|
fn run(
|
|
&self,
|
|
client_user_message_id: acp_thread::ClientUserMessageId,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
match self.thread.update(cx, |thread, cx| {
|
|
thread.truncate(client_user_message_id.clone(), cx)?;
|
|
Ok(thread.latest_token_usage())
|
|
}) {
|
|
Ok(usage) => {
|
|
self.acp_thread
|
|
.update(cx, |thread, cx| {
|
|
thread.update_token_usage(usage, cx);
|
|
})
|
|
.ok();
|
|
Task::ready(Ok(()))
|
|
}
|
|
Err(error) => Task::ready(Err(error)),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NativeAgentSessionRetry {
|
|
connection: NativeAgentConnection,
|
|
session_id: acp_v2::SessionId,
|
|
}
|
|
|
|
impl acp_thread::AgentSessionRetry for NativeAgentSessionRetry {
|
|
fn run(&self, cx: &mut App) -> Task<Result<acp_v1::PromptResponse>> {
|
|
self.connection
|
|
.run_turn(self.session_id.clone(), cx, |thread, cx| {
|
|
thread.update(cx, |thread, cx| thread.resume(cx))
|
|
})
|
|
}
|
|
}
|
|
|
|
struct NativeAgentSessionSetTitle {
|
|
thread: Entity<Thread>,
|
|
}
|
|
|
|
impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
|
|
fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>> {
|
|
self.thread
|
|
.update(cx, |thread, cx| thread.set_title(title, cx));
|
|
Task::ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
pub struct NativeThreadEnvironment {
|
|
agent: WeakEntity<NativeAgent>,
|
|
thread: WeakEntity<Thread>,
|
|
acp_thread: WeakEntity<AcpThread>,
|
|
}
|
|
|
|
impl NativeThreadEnvironment {
|
|
pub(crate) fn create_subagent_thread(
|
|
&self,
|
|
label: String,
|
|
model: Option<AgentModelId>,
|
|
cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
let model = if let Some(model_id) = model {
|
|
let available = self.agent.read_with(cx, |agent, _| {
|
|
agent.models.model_from_id(&model_id).is_some()
|
|
})?;
|
|
if !available {
|
|
anyhow::bail!(
|
|
"Model {model_id} is unavailable. Call list_agents_and_models to inspect available models."
|
|
);
|
|
}
|
|
Some(subagent_model_id_to_selection(&model_id, cx))
|
|
} else {
|
|
None
|
|
};
|
|
let Some(parent_thread_entity) = self.thread.upgrade() else {
|
|
anyhow::bail!("Parent thread no longer exists".to_string());
|
|
};
|
|
let parent_thread = parent_thread_entity.read(cx);
|
|
let current_depth = parent_thread.depth();
|
|
let parent_session_id = parent_thread.id().clone();
|
|
|
|
if current_depth >= MAX_SUBAGENT_DEPTH {
|
|
return Err(anyhow!(
|
|
"Maximum subagent depth ({}) reached",
|
|
MAX_SUBAGENT_DEPTH
|
|
));
|
|
}
|
|
|
|
let subagent_thread: Entity<Thread> = cx.new(|cx| {
|
|
let mut thread = Thread::new_subagent(&parent_thread_entity, model.as_ref(), cx);
|
|
thread.set_title(label.into(), cx);
|
|
thread
|
|
});
|
|
|
|
let session_id = subagent_thread.read(cx).id().clone();
|
|
|
|
let acp_thread = self
|
|
.agent
|
|
.update(cx, |agent, cx| -> Result<Entity<AcpThread>> {
|
|
let project_id = agent
|
|
.sessions
|
|
.get(&parent_session_id)
|
|
.map(|s| s.project_id)
|
|
.context("parent session not found")?;
|
|
let acp_thread = agent.register_session(subagent_thread.clone(), project_id, cx);
|
|
let parent_session = agent
|
|
.sessions
|
|
.get_mut(&parent_session_id)
|
|
.context("parent session not found")?;
|
|
parent_session.subagents.push(acp_thread.clone());
|
|
Ok(acp_thread)
|
|
})??;
|
|
|
|
let depth = current_depth + 1;
|
|
|
|
telemetry::event!(
|
|
"Subagent Started",
|
|
session = parent_thread_entity.read(cx).id().to_string(),
|
|
subagent_session = session_id.to_string(),
|
|
depth,
|
|
is_resumed = false,
|
|
);
|
|
|
|
self.prompt_subagent(session_id, subagent_thread, acp_thread)
|
|
}
|
|
|
|
pub(crate) fn resume_subagent_thread(
|
|
&self,
|
|
session_id: acp_v2::SessionId,
|
|
cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
let (subagent_thread, acp_thread) = self.agent.update(cx, |agent, _cx| {
|
|
let session = agent
|
|
.sessions
|
|
.get(&session_id)
|
|
.ok_or_else(|| anyhow!("No subagent session found with id {session_id}"))?;
|
|
let acp_thread = session
|
|
.acp_thread
|
|
.upgrade()
|
|
.ok_or_else(|| anyhow!("Subagent session {session_id} was released"))?;
|
|
anyhow::Ok((session.thread.clone(), acp_thread))
|
|
})??;
|
|
|
|
let depth = subagent_thread.read(cx).depth();
|
|
|
|
if let Some(parent_thread_entity) = self.thread.upgrade() {
|
|
telemetry::event!(
|
|
"Subagent Started",
|
|
session = parent_thread_entity.read(cx).id().to_string(),
|
|
subagent_session = session_id.to_string(),
|
|
depth,
|
|
is_resumed = true,
|
|
);
|
|
}
|
|
|
|
self.prompt_subagent(session_id, subagent_thread, acp_thread)
|
|
}
|
|
|
|
fn prompt_subagent(
|
|
&self,
|
|
session_id: acp_v2::SessionId,
|
|
subagent_thread: Entity<Thread>,
|
|
acp_thread: Entity<acp_thread::AcpThread>,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
let Some(parent_thread_entity) = self.thread.upgrade() else {
|
|
anyhow::bail!("Parent thread no longer exists".to_string());
|
|
};
|
|
Ok(Rc::new(NativeSubagentHandle::new(
|
|
session_id,
|
|
subagent_thread,
|
|
acp_thread,
|
|
parent_thread_entity,
|
|
)) as _)
|
|
}
|
|
}
|
|
|
|
impl ThreadEnvironment for NativeThreadEnvironment {
|
|
fn create_terminal(
|
|
&self,
|
|
command: String,
|
|
extra_env: Vec<acp_v1::EnvVariable>,
|
|
cwd: Option<PathBuf>,
|
|
output_byte_limit: Option<u64>,
|
|
sandbox_wrap: Option<acp_thread::SandboxWrap>,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Result<Rc<dyn TerminalHandle>>> {
|
|
// On Seatbelt-style sandboxes (macOS) there's no tmpfs overlay, so to
|
|
// give the command a writable temp area we point `$TMPDIR`/`$TMP`/
|
|
// `$TEMP` at a per-thread directory inside the sandbox's writable
|
|
// scope. Doing this even when sandboxing is disabled keeps `$TMPDIR`
|
|
// stable so the model can't infer sandbox state from it.
|
|
//
|
|
// Only do this for local projects. For remote projects the temp
|
|
// directory would be created on the client, but the terminal runs on
|
|
// the remote host, so pointing `$TMPDIR` (and the sandbox writable
|
|
// scope) at a client-side path would leak client environment into the
|
|
// remote terminal and reference a directory that doesn't exist there.
|
|
//
|
|
// Linux and Windows are excluded: the bwrap sandbox (run directly on
|
|
// Linux, and via WSL on Windows) already mounts a fresh, writable
|
|
// `tmpfs` over `/tmp`, so the environment looks like a normal
|
|
// filesystem with no special `$TMPDIR` (which would only make the
|
|
// sandbox more obviously Zed-specific). On Windows a per-thread
|
|
// `$TMPDIR` would also be a Windows path that's meaningless inside
|
|
// WSL, and adding it to the writable scope would bind a stray
|
|
// `/mnt/<drive>/...` path.
|
|
#[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))]
|
|
let mut extra_env = extra_env;
|
|
#[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))]
|
|
let mut sandbox_wrap = sandbox_wrap;
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
{
|
|
let temp_dir = self.thread.update(cx, |thread, cx| {
|
|
thread
|
|
.project()
|
|
.read(cx)
|
|
.is_local()
|
|
.then(|| thread.sandboxed_terminal_temp_dir(cx))
|
|
});
|
|
match temp_dir {
|
|
Ok(Some(Ok(temp_dir))) => {
|
|
// Canonicalize so the path matches what the sandbox
|
|
// resolves symlinks to (e.g. `/var` -> `/private/var` on
|
|
// macOS). `$TMPDIR` and the writable-scope entry below must
|
|
// agree, and they must agree with the path the kernel
|
|
// actually checks.
|
|
let temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir);
|
|
let temp_dir_string = temp_dir.to_string_lossy().into_owned();
|
|
extra_env.extend([
|
|
acp_v1::EnvVariable::new("TMPDIR", &temp_dir_string),
|
|
acp_v1::EnvVariable::new("TMP", &temp_dir_string),
|
|
acp_v1::EnvVariable::new("TEMP", &temp_dir_string),
|
|
]);
|
|
// The command's `$TMPDIR` must live inside the sandbox's
|
|
// writable scope. The per-thread temp directory is owned
|
|
// here (not in the terminal tool that assembles the rest
|
|
// of the writable set), so add it whenever the command is
|
|
// sandboxed.
|
|
if let Some(sandbox_wrap) = &mut sandbox_wrap {
|
|
sandbox_wrap.writable_paths.push(temp_dir);
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Ok(Some(Err(error))) => return Task::ready(Err(error)),
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
}
|
|
let task = self.acp_thread.update(cx, |thread, cx| {
|
|
thread.create_terminal(
|
|
command,
|
|
vec![],
|
|
extra_env,
|
|
cwd,
|
|
output_byte_limit,
|
|
sandbox_wrap,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let acp_thread = self.acp_thread.clone();
|
|
cx.spawn(async move |cx| {
|
|
let terminal = task?.await?;
|
|
|
|
let (drop_tx, drop_rx) = oneshot::channel();
|
|
let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone());
|
|
|
|
cx.spawn(async move |cx| {
|
|
drop_rx.await.ok();
|
|
acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
|
|
})
|
|
.detach();
|
|
|
|
let handle = AcpTerminalHandle {
|
|
terminal,
|
|
_drop_tx: Some(drop_tx),
|
|
};
|
|
|
|
Ok(Rc::new(handle) as _)
|
|
})
|
|
}
|
|
|
|
fn create_subagent(
|
|
&self,
|
|
label: String,
|
|
model: Option<AgentModelId>,
|
|
cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
self.create_subagent_thread(label, model, cx)
|
|
}
|
|
|
|
fn resume_subagent(
|
|
&self,
|
|
session_id: acp_v2::SessionId,
|
|
cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
self.resume_subagent_thread(session_id, cx)
|
|
}
|
|
|
|
fn create_sibling_thread(
|
|
&self,
|
|
request: SiblingThreadRequest,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Result<SiblingThreadInfo>> {
|
|
let host = match self
|
|
.agent
|
|
.read_with(cx, |agent, _| agent.sibling_thread_host())
|
|
{
|
|
Ok(Some(host)) => host,
|
|
Ok(None) => {
|
|
return Task::ready(Err(anyhow!(
|
|
"No sibling-thread host is registered. This usually means the \
|
|
agent panel hasn't been initialized in this workspace."
|
|
)));
|
|
}
|
|
Err(err) => return Task::ready(Err(err)),
|
|
};
|
|
host.create_sibling_thread(request, cx)
|
|
}
|
|
|
|
fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents> {
|
|
let host = self
|
|
.agent
|
|
.read_with(cx, |agent, _| agent.sibling_thread_host())?;
|
|
if let Some(host) = host {
|
|
host.list_available_agents(cx)
|
|
} else {
|
|
Ok(AvailableAgents {
|
|
agents: vec![available_native_agent(cx)],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct NativeSubagentHandle {
|
|
session_id: acp_v2::SessionId,
|
|
parent_thread: WeakEntity<Thread>,
|
|
subagent_thread: Entity<Thread>,
|
|
acp_thread: Entity<acp_thread::AcpThread>,
|
|
}
|
|
|
|
impl NativeSubagentHandle {
|
|
fn new(
|
|
session_id: acp_v2::SessionId,
|
|
subagent_thread: Entity<Thread>,
|
|
acp_thread: Entity<acp_thread::AcpThread>,
|
|
parent_thread_entity: Entity<Thread>,
|
|
) -> Self {
|
|
NativeSubagentHandle {
|
|
session_id,
|
|
subagent_thread,
|
|
parent_thread: parent_thread_entity.downgrade(),
|
|
acp_thread,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SubagentHandle for NativeSubagentHandle {
|
|
fn id(&self) -> acp_v2::SessionId {
|
|
self.session_id.clone()
|
|
}
|
|
|
|
fn num_entries(&self, cx: &App) -> usize {
|
|
self.acp_thread.read(cx).entries().len()
|
|
}
|
|
|
|
fn send(&self, message: String, cx: &AsyncApp) -> Task<Result<String>> {
|
|
let thread = self.subagent_thread.clone();
|
|
let acp_thread = self.acp_thread.clone();
|
|
let subagent_session_id = self.session_id.clone();
|
|
let parent_thread = self.parent_thread.clone();
|
|
|
|
cx.spawn(async move |cx| {
|
|
let (task, token_limit_rx, _subscription) = cx.update(|cx| {
|
|
parent_thread
|
|
.update(cx, |parent_thread, _cx| {
|
|
parent_thread.register_running_subagent(thread.downgrade())
|
|
})
|
|
.ok();
|
|
|
|
let ratio_before_prompt = thread
|
|
.read(cx)
|
|
.latest_token_usage()
|
|
.map_or(TokenUsageRatio::Normal, |usage| usage.ratio());
|
|
let (token_limit_tx, token_limit_rx) = oneshot::channel();
|
|
let mut token_limit_tx = Some(token_limit_tx);
|
|
let subscription = cx.subscribe(
|
|
&thread,
|
|
move |thread, event: &TokenUsageUpdated, cx| {
|
|
if !thread.read(cx).auto_compaction_enabled(cx)
|
|
&& event.0.as_ref().is_some_and(|usage| usage.ratio() > ratio_before_prompt)
|
|
&& let Some(sender) = token_limit_tx.take()
|
|
{
|
|
sender.send(()).ok();
|
|
}
|
|
},
|
|
);
|
|
let task = acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.send(vec![message.into()], cx)
|
|
});
|
|
(task, token_limit_rx, subscription)
|
|
});
|
|
|
|
let mut task = task.fuse();
|
|
let response = futures::select_biased! {
|
|
response = task => response,
|
|
_ = token_limit_rx.fuse() => {
|
|
if thread.read_with(cx, |thread, _| thread.is_turn_complete()) {
|
|
task.await
|
|
} else {
|
|
thread.update(cx, |thread, cx| thread.cancel(cx)).await;
|
|
Err(anyhow!(
|
|
"The agent is nearing the end of its context window and has been \
|
|
stopped. You can prompt the thread again to have the agent wrap up \
|
|
or hand off its work."
|
|
))
|
|
}
|
|
}
|
|
};
|
|
let discard_partial_output = matches!(
|
|
&response,
|
|
Ok(Some(acp_thread::SubmissionResponse::LegacyCompleted(response))) if response.stop_reason == acp_v1::StopReason::Cancelled
|
|
|| response.stop_reason == acp_v1::StopReason::Refusal
|
|
);
|
|
let result = match response {
|
|
Ok(Some(acp_thread::SubmissionResponse::LegacyCompleted(response))) => match response.stop_reason {
|
|
acp_v1::StopReason::Cancelled => Err(anyhow!("User canceled")),
|
|
acp_v1::StopReason::MaxTokens => Err(anyhow!("The agent reached the maximum number of tokens.")),
|
|
acp_v1::StopReason::MaxTurnRequests => Err(anyhow!("The agent reached the maximum number of allowed requests between user turns. Try prompting again.")),
|
|
acp_v1::StopReason::Refusal => Err(anyhow!("The agent refused to process that prompt. Try again.")),
|
|
_ => thread.read_with(cx, |thread, _cx| {
|
|
thread
|
|
.last_message()
|
|
.and_then(|message| {
|
|
let content = message.as_agent_message()?
|
|
.content
|
|
.iter()
|
|
.filter_map(|content| match content {
|
|
AgentMessageContent::Text(text) => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.join("\n\n");
|
|
if content.is_empty() {
|
|
None
|
|
} else {
|
|
Some(content)
|
|
}
|
|
})
|
|
.context("No response from subagent")
|
|
}),
|
|
},
|
|
Ok(Some(acp_thread::SubmissionResponse::Accepted(_))) => {
|
|
Err(anyhow!("Native subagent returned acceptance instead of turn completion"))
|
|
}
|
|
Ok(None) => Err(anyhow!("No response from the agent. You can try messaging again.")),
|
|
Err(error) => Err(error),
|
|
};
|
|
|
|
parent_thread
|
|
.update(cx, |parent_thread, cx| {
|
|
parent_thread.unregister_running_subagent(&subagent_session_id, cx)
|
|
})
|
|
.ok();
|
|
|
|
if discard_partial_output {
|
|
result
|
|
} else {
|
|
result.map_err(|error| {
|
|
let partial_output = thread.read_with(cx, |thread, _| thread.subagent_partial_output());
|
|
if partial_output.is_empty() {
|
|
anyhow!("{error:#}")
|
|
} else {
|
|
anyhow!("{error:#}\n\nPartial subagent output (last 3 messages, up to 4096 characters each):\n\n{partial_output}")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct AcpTerminalHandle {
|
|
terminal: Entity<acp_thread::Terminal>,
|
|
_drop_tx: Option<oneshot::Sender<()>>,
|
|
}
|
|
|
|
impl TerminalHandle for AcpTerminalHandle {
|
|
fn id(&self, cx: &AsyncApp) -> Result<acp_v2::TerminalId> {
|
|
Ok(self.terminal.read_with(cx, |term, _cx| term.id().clone()))
|
|
}
|
|
|
|
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp_v1::TerminalExitStatus>>> {
|
|
self.terminal
|
|
.read_with(cx, |term, _cx| term.wait_for_exit())
|
|
}
|
|
|
|
fn current_output(&self, cx: &AsyncApp) -> Result<acp_v1::TerminalOutputResponse> {
|
|
Ok(self
|
|
.terminal
|
|
.read_with(cx, |term, cx| term.current_output(cx)))
|
|
}
|
|
|
|
fn kill(&self, cx: &AsyncApp) -> Result<()> {
|
|
cx.update(|cx| {
|
|
self.terminal.update(cx, |terminal, cx| {
|
|
terminal.kill(cx);
|
|
});
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result<bool> {
|
|
Ok(self
|
|
.terminal
|
|
.read_with(cx, |term, _cx| term.was_stopped_by_user()))
|
|
}
|
|
}
|
|
|
|
/// Build the catalog the model sees in its system prompt: filter out hidden
|
|
/// (`disable_model_invocation`) skills, then drop the rest if they would push
|
|
/// the catalog past the description budget.
|
|
///
|
|
/// Returns `SkillSummary` values rather than full `Skill`s so that the
|
|
/// (potentially ~100KB) skill bodies aren't cloned just to be discarded by
|
|
/// `ProjectContext::new`, which only needs the summary fields.
|
|
fn select_catalog_skills(skills: &[Skill]) -> (Vec<SkillSummary>, Vec<SkillLoadingIssueData>) {
|
|
let mut kept = Vec::new();
|
|
let mut issues = Vec::new();
|
|
let mut dropped: Vec<&Skill> = Vec::new();
|
|
let mut total_size = 0usize;
|
|
let mut budget_exceeded = false;
|
|
|
|
for skill in skills {
|
|
if skill.disable_model_invocation {
|
|
continue;
|
|
}
|
|
|
|
let entry_size = skill.name.len() + skill.description.len();
|
|
if !budget_exceeded && total_size.saturating_add(entry_size) <= MAX_SKILL_DESCRIPTIONS_SIZE
|
|
{
|
|
total_size += entry_size;
|
|
kept.push(SkillSummary::from(skill));
|
|
} else {
|
|
// Once any model-invocable skill overflows the budget, stop
|
|
// packing entirely so the cutoff is deterministic by sort order
|
|
// rather than dependent on which skills happen to be small
|
|
// enough to fit in the remaining space.
|
|
budget_exceeded = true;
|
|
dropped.push(skill);
|
|
}
|
|
}
|
|
|
|
if !dropped.is_empty() {
|
|
let budget_kb = MAX_SKILL_DESCRIPTIONS_SIZE / 1024;
|
|
let first = dropped[0];
|
|
let message = if dropped.len() == 1 {
|
|
let entry_size = first.name.len() + first.description.len();
|
|
format!(
|
|
"Skill '{}' ({:.1}KB description) was dropped from the catalog because the previous skills already used the entire {}KB description budget.",
|
|
first.name,
|
|
entry_size as f64 / 1024.0,
|
|
budget_kb,
|
|
)
|
|
} else {
|
|
let mut message = format!(
|
|
"{} skills were dropped from the catalog because they exceeded the {}KB description budget:",
|
|
dropped.len(),
|
|
budget_kb,
|
|
);
|
|
for skill in &dropped {
|
|
let entry_size = skill.name.len() + skill.description.len();
|
|
message.push('\n');
|
|
message.push_str(&format!(
|
|
"- {} ({:.1}KB description)",
|
|
skill.name,
|
|
entry_size as f64 / 1024.0,
|
|
));
|
|
}
|
|
message
|
|
};
|
|
issues.push(SkillLoadingIssueData::catalog_budget_exceeded(
|
|
first.skill_file_path.clone(),
|
|
message,
|
|
));
|
|
}
|
|
|
|
(kept, issues)
|
|
}
|
|
|
|
/// Build a closure that, when called, reads the latest `state.skills`
|
|
/// for the given project from the `NativeAgent` and applies
|
|
/// project-overrides-global so the `SkillTool` resolves a name to the
|
|
/// same entry the model sees in its catalog. Run at invocation time
|
|
/// (not thread-build time) so skill changes after thread construction
|
|
/// become visible without re-registering the tool.
|
|
pub fn skills_resolver_for_project(
|
|
weak_agent: WeakEntity<NativeAgent>,
|
|
project_id: EntityId,
|
|
) -> impl Fn(&App) -> Arc<Vec<Skill>> + Send + Sync + 'static {
|
|
move |cx: &App| {
|
|
weak_agent
|
|
.upgrade()
|
|
.and_then(|agent| {
|
|
agent
|
|
.read(cx)
|
|
.projects
|
|
.get(&project_id)
|
|
.map(|state| Arc::new(apply_skill_overrides(&state.skills)))
|
|
})
|
|
.unwrap_or_else(|| Arc::new(Vec::new()))
|
|
}
|
|
}
|
|
|
|
pub fn skill_body_resolver_for_project(
|
|
project: Entity<Project>,
|
|
fs: Arc<dyn Fs>,
|
|
) -> impl Fn(Skill, &mut AsyncApp) -> Task<Result<String>> + Send + Sync + 'static {
|
|
move |skill, cx| match skill.source.clone() {
|
|
SkillSource::ProjectLocal { worktree_id, .. } => {
|
|
let project = project.clone();
|
|
cx.spawn(async move |cx| {
|
|
let worktree_id = WorktreeId::from_usize(worktree_id.0);
|
|
let worktree = project
|
|
.update(cx, |project, cx| project.worktree_for_id(worktree_id, cx))
|
|
.context("no such worktree")?;
|
|
expand_project_skills_directories(&worktree, cx).await?;
|
|
let relative_path = worktree.update(cx, |worktree, _cx| {
|
|
let worktree_root = worktree.abs_path();
|
|
worktree
|
|
.path_style()
|
|
.strip_prefix(&skill.skill_file_path, &worktree_root)
|
|
.map(|relative_path| relative_path.into_arc())
|
|
.context("skill file is not inside its worktree")
|
|
})?;
|
|
|
|
let buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer((worktree_id, relative_path), cx)
|
|
})
|
|
.await?;
|
|
let content =
|
|
cx.update(|cx| buffer.read(cx).as_text_snapshot().as_rope().to_string());
|
|
|
|
read_skill_body_from_content(&skill.skill_file_path, &content).map_err(Into::into)
|
|
})
|
|
}
|
|
SkillSource::BuiltIn | SkillSource::Global => {
|
|
let fs = fs.clone();
|
|
cx.background_spawn(async move {
|
|
agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
|
|
.await
|
|
.map_err(Into::into)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collect successfully-loaded global and project-local skills into a
|
|
/// single list, preserving every entry — even when two skills share a
|
|
/// name. The autocomplete popup shows the full list with origin labels
|
|
/// so users can tell same-named skills apart; override resolution
|
|
/// (project-local wins over global) happens later via
|
|
/// [`apply_skill_overrides`] at the boundaries where the model
|
|
/// interacts with skills (system-prompt catalog, `SkillTool` lookup,
|
|
/// slash-command invocation).
|
|
///
|
|
/// Global versions of skills will be before the local versions
|
|
fn combine_skills(
|
|
global: Vec<Result<Skill, SkillLoadError>>,
|
|
project: impl Iterator<Item = Result<Skill, SkillLoadError>>,
|
|
) -> (Vec<Skill>, Vec<SkillLoadError>) {
|
|
// Built-in skills go first (lowest priority) so that global and
|
|
// project-local skills with the same name shadow them.
|
|
let mut skills = builtin_skills();
|
|
let mut errors = Vec::new();
|
|
for result in global.into_iter().chain(project) {
|
|
match result {
|
|
Ok(skill) => skills.push(skill),
|
|
Err(e) => errors.push(e),
|
|
}
|
|
}
|
|
log_skill_conflicts(&skills);
|
|
(skills, errors)
|
|
}
|
|
|
|
/// Emit a warning for each name collision between skills. Called once
|
|
/// per skill load (not per query), so the log isn't spammed by repeated
|
|
/// catalog rebuilds.
|
|
fn log_skill_conflicts(skills: &[Skill]) {
|
|
let mut by_name: HashMap<&str, &Skill> = HashMap::default();
|
|
for skill in skills {
|
|
match by_name.get(skill.name.as_str()) {
|
|
Some(existing) => {
|
|
if skill.source.precedence() > existing.source.precedence() {
|
|
log::warn!(
|
|
"Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source",
|
|
skill.name,
|
|
skill.skill_file_path.display(),
|
|
existing.skill_file_path.display(),
|
|
);
|
|
by_name.insert(skill.name.as_str(), skill);
|
|
} else {
|
|
log::warn!(
|
|
"Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source",
|
|
skill.name,
|
|
skill.skill_file_path.display(),
|
|
existing.skill_file_path.display(),
|
|
);
|
|
}
|
|
}
|
|
None => {
|
|
by_name.insert(skill.name.as_str(), skill);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Project-local skills override same-named global skills. Returns a
|
|
/// new list with at most one entry per name. Two skills of the same
|
|
/// source colliding (e.g. two globals or two project-locals) keep the
|
|
/// first one to match the historical behavior.
|
|
///
|
|
/// This is the projection of `state.skills` used by everything the
|
|
/// model interacts with: the system-prompt catalog, the `SkillTool`'s
|
|
/// name resolver, and slash-command invocation. The autocomplete popup
|
|
/// deliberately does *not* go through this — it shows the full list so
|
|
/// users can see what's shadowed.
|
|
fn apply_skill_overrides(skills: &[Skill]) -> Vec<Skill> {
|
|
let mut result: Vec<Skill> = Vec::new();
|
|
// Borrow names from the input slice so the dedup index doesn't
|
|
// need to allocate a `String` per skill. The borrow is valid for
|
|
// the body of the function because `skills` outlives `indices`.
|
|
let mut indices: HashMap<&str, usize> = HashMap::default();
|
|
for skill in skills {
|
|
match indices.get(skill.name.as_str()).copied() {
|
|
Some(idx) => {
|
|
if skill.source.precedence() > result[idx].source.precedence() {
|
|
result[idx] = skill.clone();
|
|
}
|
|
}
|
|
None => {
|
|
indices.insert(skill.name.as_str(), result.len());
|
|
result.push(skill.clone());
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod internal_tests {
|
|
use std::path::Path;
|
|
|
|
use super::*;
|
|
use crate::tests::release_dropped_entities;
|
|
use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri};
|
|
use agent_settings::COMPACTION_PROMPT;
|
|
use fs::FakeFs;
|
|
use gpui::TestAppContext;
|
|
use indoc::formatdoc;
|
|
use language_model::fake_provider::FakeLanguageModelProvider;
|
|
use language_model::{
|
|
CompletionIntent, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
|
LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, Speed,
|
|
};
|
|
use serde_json::json;
|
|
use settings::{LanguageModelProviderSetting, SettingsStore};
|
|
use util::{path, rel_path::rel_path};
|
|
|
|
#[test]
|
|
fn native_stop_reasons_preserve_legacy_completions() -> Result<()> {
|
|
for (native, legacy) in [
|
|
(acp_v2::StopReason::EndTurn, acp_v1::StopReason::EndTurn),
|
|
(acp_v2::StopReason::MaxTokens, acp_v1::StopReason::MaxTokens),
|
|
(
|
|
acp_v2::StopReason::MaxTurnRequests,
|
|
acp_v1::StopReason::MaxTurnRequests,
|
|
),
|
|
(acp_v2::StopReason::Refusal, acp_v1::StopReason::Refusal),
|
|
(acp_v2::StopReason::Cancelled, acp_v1::StopReason::Cancelled),
|
|
] {
|
|
assert_eq!(legacy_native_completion(native)?.stop_reason, legacy);
|
|
}
|
|
assert!(
|
|
legacy_native_completion(acp_v2::StopReason::Other(acp_v2::OtherStopReason::new(
|
|
"_future",
|
|
Default::default(),
|
|
)))
|
|
.is_err(),
|
|
"an unsupported reason must not masquerade as successful completion"
|
|
);
|
|
let error = legacy_native_completion(acp_v2::ErrorStopReason::new().into())
|
|
.expect_err("an error without details is not successful completion");
|
|
assert_eq!(error.to_string(), "Native agent stopped with an error");
|
|
let details = acp_v2::Error::auth_required().data(json!({"hint": "sign in"}));
|
|
let error =
|
|
legacy_native_completion(acp_v2::ErrorStopReason::new().error(details.clone()).into())
|
|
.expect_err("reported error details propagate");
|
|
assert_eq!(error.downcast_ref::<acp_v2::Error>(), Some(&details));
|
|
Ok(())
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn test_available_native_agent_hides_hidden_providers(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::test(cx);
|
|
let registry = LanguageModelRegistry::global(cx);
|
|
registry.update(cx, |registry, cx| {
|
|
registry.set_builtin_provider_hiding_fn(Box::new(|id| {
|
|
(id == "fake").then_some("fake-extension")
|
|
}));
|
|
registry.extension_installed("fake-extension".into(), cx);
|
|
});
|
|
|
|
assert!(available_native_agent(cx).models.is_empty());
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_explicit_subagent_model_preserves_configured_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(path!("/test"), json!({})).await;
|
|
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
let provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
let model = provider.update_model("subagent-model", |model| {
|
|
model.name = LanguageModelName::from("Subagent Model".to_string());
|
|
model.supports_thinking = true;
|
|
});
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(provider, cx);
|
|
});
|
|
});
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
let mut settings = cx.update(|cx| agent_settings::AgentSettings::get_global(cx).clone());
|
|
settings.subagent_model = Some(LanguageModelSelection {
|
|
provider: LanguageModelProviderSetting("fake-corp".to_string()),
|
|
model: "subagent-model".to_string(),
|
|
enable_thinking: true,
|
|
effort: Some("high".to_string()),
|
|
speed: Some(Speed::Fast),
|
|
});
|
|
cx.update(|cx| agent_settings::AgentSettings::override_global(settings, cx));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| connection.new_session(project, PathList::new(&[Path::new("/test")]), cx))
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let parent_thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
let environment = NativeThreadEnvironment {
|
|
agent: agent.downgrade(),
|
|
thread: parent_thread.downgrade(),
|
|
acp_thread: acp_thread.downgrade(),
|
|
};
|
|
|
|
let handle = cx
|
|
.update(|cx| {
|
|
environment.create_subagent_thread(
|
|
"subagent".to_string(),
|
|
Some(AgentModelId::from("fake-corp/subagent-model".to_string())),
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap();
|
|
let subagent_thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&handle.id()).unwrap().thread.clone()
|
|
});
|
|
subagent_thread.read_with(cx, |thread, _| {
|
|
assert_eq!(thread.model().map(|model| model.id()), Some(model.id()));
|
|
assert!(thread.thinking_enabled());
|
|
assert_eq!(thread.thinking_effort(), Some(&"high".to_string()));
|
|
// The fake model does not support fast mode, so the configured speed is
|
|
// intentionally filtered while the model selection is applied.
|
|
assert_eq!(thread.speed(), None);
|
|
});
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[gpui::test]
|
|
async fn test_native_terminal_tool_releases_pty_resources(cx: &mut TestAppContext) {
|
|
use feature_flags::FeatureFlagAppExt as _;
|
|
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
cx.update(|cx| {
|
|
cx.update_flags(true, vec!["sandboxing".to_string()]);
|
|
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
|
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
|
|
settings.tool_permissions.tools.remove(TerminalTool::NAME);
|
|
settings.sandbox_permissions = agent_settings::SandboxPermissions::default();
|
|
agent_settings::AgentSettings::override_global(settings, cx);
|
|
});
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create terminal working directory");
|
|
let fs = fs::RealFs::new(None, cx.executor());
|
|
let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.new_session(project.clone(), PathList::new(&[temp_dir.path()]), cx)
|
|
})
|
|
.await
|
|
.expect("create native agent session");
|
|
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let environment = Rc::new(NativeThreadEnvironment {
|
|
agent: agent.downgrade(),
|
|
thread: thread.downgrade(),
|
|
acp_thread: acp_thread.downgrade(),
|
|
});
|
|
|
|
#[allow(clippy::arc_with_non_send_sync)]
|
|
let tool = Arc::new(SandboxedTerminalTool::new(project, environment));
|
|
let (event_stream, mut receiver) = ToolCallEventStream::test();
|
|
let task = cx.update(|cx| {
|
|
tool.run(
|
|
ToolInput::resolved(SandboxedTerminalToolInput {
|
|
command: "read -r line </dev/tty".to_string(),
|
|
cd: temp_dir.path().to_string_lossy().into_owned(),
|
|
..Default::default()
|
|
}),
|
|
event_stream,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let authorization = receiver.expect_authorization().await;
|
|
authorization
|
|
.response
|
|
.send(acp_thread::SelectedPermissionOutcome::new(
|
|
acp_v2::PermissionOptionId::new("allow"),
|
|
acp_v2::PermissionOptionKind::AllowOnce,
|
|
))
|
|
.expect("authorization response should send");
|
|
|
|
let update = receiver.expect_update_fields().await;
|
|
let terminal_id = update
|
|
.content
|
|
.value()
|
|
.into_iter()
|
|
.flatten()
|
|
.find_map(|content| match content {
|
|
acp_v2::ToolCallContent::Terminal(terminal) => Some(terminal.terminal_id.clone()),
|
|
_ => None,
|
|
})
|
|
.expect("terminal tool should announce its real terminal");
|
|
let historical_terminal = acp_thread
|
|
.read_with(cx, |thread, _cx| thread.terminal(terminal_id.clone()))
|
|
.expect("terminal should remain available while the tool call is running")
|
|
.read_with(cx, |terminal, _cx| terminal.inner().clone());
|
|
assert!(
|
|
historical_terminal.read_with(cx, |terminal, _cx| terminal.has_active_pty_resources()),
|
|
"the full terminal tool path should create a real PTY"
|
|
);
|
|
|
|
historical_terminal.update(cx, |terminal, _cx| terminal.input(b"\n".to_vec()));
|
|
|
|
let result = task.await.expect("native terminal tool call succeeds");
|
|
assert_eq!(result, "Command executed successfully.");
|
|
assert!(
|
|
historical_terminal.read_with(cx, |terminal, _cx| {
|
|
terminal.is_pty() && terminal.pid_getter().is_some()
|
|
}),
|
|
"completed terminal history should retain PTY process metadata"
|
|
);
|
|
assert!(
|
|
!historical_terminal.read_with(cx, |terminal, _cx| terminal.has_active_pty_resources()),
|
|
"completed terminal history should not retain active PTY resources"
|
|
);
|
|
|
|
cx.run_until_parked();
|
|
assert!(
|
|
acp_thread.read_with(cx, |thread, _cx| thread.terminal(terminal_id).is_err()),
|
|
"the full terminal tool call should release its terminal from the ACP thread"
|
|
);
|
|
assert!(
|
|
!historical_terminal.read_with(cx, |terminal, _cx| terminal.has_active_pty_resources()),
|
|
"releasing the ACP terminal should keep PTY resources released"
|
|
);
|
|
}
|
|
|
|
fn make_global_skill(name: &str, description: &str) -> Skill {
|
|
Skill {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")),
|
|
skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
}
|
|
}
|
|
|
|
async fn setup_native_agent_session(
|
|
cx: &mut TestAppContext,
|
|
) -> (
|
|
Rc<NativeAgentConnection>,
|
|
Entity<NativeAgent>,
|
|
Entity<Project>,
|
|
Entity<AcpThread>,
|
|
) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [Path::new("/a")], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
(connection, agent, project, acp_thread)
|
|
}
|
|
|
|
fn native_thread_for_session(
|
|
agent: &Entity<NativeAgent>,
|
|
session_id: &acp_v2::SessionId,
|
|
cx: &App,
|
|
) -> Entity<Thread> {
|
|
agent.read_with(cx, |agent, _cx| {
|
|
agent.sessions.get(session_id).unwrap().thread.clone()
|
|
})
|
|
}
|
|
|
|
fn request_texts_after_system(
|
|
messages: &[language_model::LanguageModelRequestMessage],
|
|
) -> Vec<String> {
|
|
messages
|
|
.iter()
|
|
.skip(1)
|
|
.map(language_model::LanguageModelRequestMessage::string_contents)
|
|
.collect()
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compact_command_is_available(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
let commands = acp_thread.read(cx).available_commands();
|
|
|
|
assert_eq!(
|
|
commands,
|
|
&[acp_v2::AvailableCommand::new(
|
|
COMPACT_COMMAND_NAME,
|
|
"Summarize the conversation so far to free up context",
|
|
)
|
|
.meta(acp_thread::meta_with_command_category(
|
|
acp_thread::CommandCategory::Native,
|
|
))],
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let model = fake.model("fake");
|
|
let old_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
let path_style = project.read(cx).path_style(cx);
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread.push_acp_user_block(
|
|
old_message_id,
|
|
[acp_v1::ContentBlock::from("old user")],
|
|
path_style,
|
|
cx,
|
|
);
|
|
thread.push_acp_agent_block("old assistant".into(), cx);
|
|
});
|
|
});
|
|
|
|
let compact_message_id = ClientUserMessageId::new();
|
|
let prompt_task = cx.update(|cx| {
|
|
acp_thread::AgentSessionClientUserMessageIds::prompt(
|
|
connection.as_ref(),
|
|
compact_message_id,
|
|
acp_v2::PromptRequest::new(session_id.0.clone(), vec!["/compact".into()]),
|
|
cx,
|
|
)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let request = fake.pending_completions().pop().unwrap();
|
|
assert_eq!(
|
|
request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
assert_eq!(
|
|
request_texts_after_system(&request.messages),
|
|
vec![
|
|
"old user".to_string(),
|
|
"old assistant".to_string(),
|
|
COMPACTION_PROMPT.to_string(),
|
|
]
|
|
);
|
|
|
|
let compaction_id = acp_thread.read_with(cx, |thread, _| {
|
|
let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) =
|
|
thread.entries().last()
|
|
else {
|
|
panic!("native compaction should create an ACP-visible entry");
|
|
};
|
|
assert!(thread.is_compacting());
|
|
assert!(compaction.is_in_progress());
|
|
assert_eq!(compaction.summary.blocks().len(), 0);
|
|
assert!(compaction.summary.source_blocks().is_empty());
|
|
assert!(compaction.meta.is_none());
|
|
compaction.id.clone()
|
|
});
|
|
|
|
fake.send_text(&model, &request, "retained ");
|
|
cx.run_until_parked();
|
|
let summary = acp_thread.read_with(cx, |thread, cx| {
|
|
let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) =
|
|
thread.entries().last()
|
|
else {
|
|
panic!("native compaction entry should remain in the timeline");
|
|
};
|
|
assert_eq!(compaction.id, compaction_id);
|
|
assert!(compaction.is_in_progress());
|
|
assert_eq!(
|
|
compaction.summary.source_blocks(),
|
|
&[acp_v2::ContentBlock::Text(acp_v2::TextContent::new(
|
|
"retained "
|
|
))]
|
|
);
|
|
let blocks = compaction.summary.blocks().collect::<Vec<_>>();
|
|
let [summary] = blocks.as_slice() else {
|
|
panic!("native text chunks should create one retained Markdown block");
|
|
};
|
|
let markdown = summary
|
|
.markdown()
|
|
.expect("native text should have markdown");
|
|
assert_eq!(markdown.read(cx).source().as_ref(), "retained ");
|
|
markdown.clone()
|
|
});
|
|
fake.send_text(&model, &request, "context");
|
|
fake.end_stream(&model, &request);
|
|
cx.run_until_parked();
|
|
prompt_task
|
|
.await
|
|
.expect("native compaction should complete");
|
|
acp_thread.read_with(cx, |thread, cx| {
|
|
let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) =
|
|
thread.entries().last()
|
|
else {
|
|
panic!("completed native compaction should remain in the timeline");
|
|
};
|
|
assert_eq!(compaction.id, compaction_id);
|
|
assert!(!thread.is_compacting());
|
|
assert_eq!(
|
|
compaction.status,
|
|
acp_thread::ContextCompactionStatus::Completed
|
|
);
|
|
assert!(compaction.error.is_none());
|
|
assert_eq!(compaction.summary.blocks().len(), 1);
|
|
assert_eq!(compaction.summary.markdowns().next(), Some(&summary));
|
|
assert_eq!(
|
|
compaction.summary.source_blocks(),
|
|
&[
|
|
acp_v2::ContentBlock::Text(acp_v2::TextContent::new("retained ")),
|
|
acp_v2::ContentBlock::Text(acp_v2::TextContent::new("context")),
|
|
]
|
|
);
|
|
assert_eq!(summary.read(cx).source().as_ref(), "retained context");
|
|
});
|
|
|
|
agent.update(cx, |agent, cx| agent.save_thread(thread.clone(), cx));
|
|
cx.run_until_parked();
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(!agent.sessions.contains_key(&session_id));
|
|
});
|
|
let restored = cx
|
|
.update(|cx| {
|
|
connection.clone().load_session(
|
|
session_id,
|
|
project,
|
|
PathList::new(&[Path::new("/a")]),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.expect("compacted native session should reload");
|
|
cx.run_until_parked();
|
|
restored.read_with(cx, |thread, cx| {
|
|
let compactions = thread
|
|
.entries()
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
acp_thread::AgentThreadEntry::ContextCompaction(compaction) => Some(compaction),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let [compaction] = compactions.as_slice() else {
|
|
panic!("replay should restore exactly one compaction");
|
|
};
|
|
assert_eq!(
|
|
compaction.status,
|
|
acp_thread::ContextCompactionStatus::Completed
|
|
);
|
|
assert!(!thread.is_compacting());
|
|
assert_eq!(
|
|
compaction
|
|
.summary
|
|
.blocks()
|
|
.next()
|
|
.map(|block| block.to_markdown(cx)),
|
|
Some("retained context")
|
|
);
|
|
assert_eq!(
|
|
compaction.summary.source_blocks(),
|
|
&[acp_v2::ContentBlock::Text(acp_v2::TextContent::new(
|
|
"retained context"
|
|
))]
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_load_session_replays_provider_native_compaction(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let mut saved_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await;
|
|
saved_thread.messages.push(Arc::new(Message::Compaction(
|
|
CompactionInfo::ProviderNative {
|
|
provider: LanguageModelProviderId::from("openai".to_string()),
|
|
items: vec![json!({"type": "compaction", "encrypted_content": "opaque state"})],
|
|
},
|
|
)));
|
|
let restored_session_id = acp_v2::SessionId::new("provider-native-compaction");
|
|
let database = cx
|
|
.update(|cx| ThreadsDatabase::connect(cx))
|
|
.await
|
|
.expect("thread database should connect");
|
|
database
|
|
.save_thread(
|
|
restored_session_id.clone(),
|
|
saved_thread,
|
|
PathList::new(&[Path::new("/a")]),
|
|
)
|
|
.await
|
|
.expect("provider-native compaction should save");
|
|
|
|
let restored = cx
|
|
.update(|cx| {
|
|
connection.load_session(
|
|
restored_session_id,
|
|
project,
|
|
PathList::new(&[Path::new("/a")]),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.expect("provider-native compaction should load");
|
|
cx.run_until_parked();
|
|
restored.read_with(cx, |thread, _| {
|
|
let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) =
|
|
thread.entries().last()
|
|
else {
|
|
panic!("provider-native replay should create a compaction entry");
|
|
};
|
|
assert_eq!(
|
|
compaction.status,
|
|
acp_thread::ContextCompactionStatus::Completed
|
|
);
|
|
assert_eq!(compaction.summary.blocks().len(), 0);
|
|
assert!(compaction.summary.source_blocks().is_empty());
|
|
assert!(compaction.meta.is_none());
|
|
assert!(compaction.error.is_none());
|
|
assert!(!thread.is_compacting());
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_retried_auto_compaction_terminalizes_each_attempt(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let (connection, agent, _project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let model = fake.model("fake");
|
|
|
|
cx.update(|cx| {
|
|
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
|
settings.auto_compact = agent_settings::AutoCompactSettings {
|
|
enabled: true,
|
|
threshold: agent_settings::AutoCompactThreshold::Percentage(0.5),
|
|
};
|
|
agent_settings::AgentSettings::override_global(settings, cx);
|
|
thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx));
|
|
});
|
|
|
|
let first_prompt = cx.update(|cx| {
|
|
acp_thread::AgentSessionClientUserMessageIds::prompt(
|
|
connection.as_ref(),
|
|
ClientUserMessageId::new(),
|
|
acp_v2::PromptRequest::new(session_id.0.clone(), vec!["old user".into()]),
|
|
cx,
|
|
)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let first_request = fake
|
|
.pending_completions()
|
|
.pop()
|
|
.expect("first user prompt should start a model request");
|
|
fake.send_event(
|
|
&model,
|
|
&first_request,
|
|
LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage {
|
|
input_tokens: 750_000,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
fake.send_text(&model, &first_request, "old assistant");
|
|
fake.end_stream(&model, &first_request);
|
|
cx.run_until_parked();
|
|
first_prompt
|
|
.await
|
|
.expect("first user prompt should complete");
|
|
|
|
let second_prompt = cx.update(|cx| {
|
|
acp_thread::AgentSessionClientUserMessageIds::prompt(
|
|
connection.as_ref(),
|
|
ClientUserMessageId::new(),
|
|
acp_v2::PromptRequest::new(session_id.0, vec!["new user".into()]),
|
|
cx,
|
|
)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let first_compaction_request = fake
|
|
.pending_completions()
|
|
.pop()
|
|
.expect("token threshold should start automatic compaction");
|
|
assert_eq!(
|
|
first_compaction_request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
fake.send_error(
|
|
&model,
|
|
&first_compaction_request,
|
|
LanguageModelCompletionError::from_provider_response(
|
|
language_model::ANTHROPIC_PROVIDER_NAME,
|
|
None,
|
|
Some("rate_limit_error".to_string()),
|
|
"Rate limit exceeded".to_string(),
|
|
Some(Duration::ZERO),
|
|
language_model::ProviderErrorCategory::RateLimit,
|
|
),
|
|
);
|
|
fake.end_stream(&model, &first_compaction_request);
|
|
cx.run_until_parked();
|
|
|
|
let second_compaction_request = fake
|
|
.pending_completions()
|
|
.pop()
|
|
.expect("retryable error should start another compaction attempt");
|
|
assert_eq!(
|
|
second_compaction_request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
acp_thread.read_with(cx, |thread, _cx| {
|
|
let statuses = thread
|
|
.entries()
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
acp_thread::AgentThreadEntry::ContextCompaction(compaction) => {
|
|
Some(compaction.status.clone())
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
statuses,
|
|
[
|
|
acp_thread::ContextCompactionStatus::Failed,
|
|
acp_thread::ContextCompactionStatus::InProgress,
|
|
]
|
|
);
|
|
assert!(thread.is_compacting());
|
|
});
|
|
|
|
fake.send_text(&model, &second_compaction_request, "summary");
|
|
fake.end_stream(&model, &second_compaction_request);
|
|
cx.run_until_parked();
|
|
|
|
acp_thread.read_with(cx, |thread, _cx| {
|
|
let statuses = thread
|
|
.entries()
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
acp_thread::AgentThreadEntry::ContextCompaction(compaction) => {
|
|
Some(compaction.status.clone())
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
statuses,
|
|
[
|
|
acp_thread::ContextCompactionStatus::Failed,
|
|
acp_thread::ContextCompactionStatus::Completed,
|
|
]
|
|
);
|
|
assert!(
|
|
!thread.is_compacting(),
|
|
"a successful retry must not leave an older attempt in progress"
|
|
);
|
|
});
|
|
|
|
let final_request = fake
|
|
.pending_completions()
|
|
.pop()
|
|
.expect("successful compaction should continue the user turn");
|
|
assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt));
|
|
fake.send_text(&model, &final_request, "new assistant");
|
|
fake.end_stream(&model, &final_request);
|
|
cx.run_until_parked();
|
|
second_prompt
|
|
.await
|
|
.expect("user prompt should complete after the compaction retry");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_native_compaction_cancellation_is_bridged_before_stop(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
|
|
for (scenario, cancel_before_first_poll, partial_summary) in [
|
|
("before first poll", true, None),
|
|
("after initial update", false, None),
|
|
(
|
|
"after partial summary",
|
|
false,
|
|
Some("retained partial summary"),
|
|
),
|
|
] {
|
|
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let model = fake.model("fake");
|
|
|
|
cx.update(|cx| {
|
|
let path_style = project.read(cx).path_style(cx);
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread.push_acp_user_block(
|
|
ClientUserMessageId::new(),
|
|
[acp_v1::ContentBlock::from("old user")],
|
|
path_style,
|
|
cx,
|
|
);
|
|
thread.push_acp_agent_block("old assistant".into(), cx);
|
|
});
|
|
});
|
|
|
|
let (response_stream, cancellation_task) = if cancel_before_first_poll {
|
|
thread.update(cx, |thread, cx| {
|
|
let response_stream = thread
|
|
.compact(ClientUserMessageId::new(), cx)
|
|
.expect("manual compaction should start");
|
|
let cancellation_task = thread.cancel(cx);
|
|
(response_stream, cancellation_task)
|
|
})
|
|
} else {
|
|
let response_stream = thread
|
|
.update(cx, |thread, cx| {
|
|
thread.compact(ClientUserMessageId::new(), cx)
|
|
})
|
|
.expect("manual compaction should start");
|
|
cx.run_until_parked();
|
|
|
|
let request = fake
|
|
.pending_completions()
|
|
.pop()
|
|
.expect("manual compaction should reach the model");
|
|
if let Some(partial_summary) = partial_summary {
|
|
fake.send_text(&model, &request, partial_summary);
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
let cancellation_task = thread.update(cx, |thread, cx| thread.cancel(cx));
|
|
(response_stream, cancellation_task)
|
|
};
|
|
cancellation_task.await;
|
|
|
|
let response = cx
|
|
.update(|cx| {
|
|
NativeAgentConnection::handle_thread_events(
|
|
response_stream,
|
|
acp_thread.downgrade(),
|
|
Some(connection.as_ref().clone()),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.expect("canceled compaction events should be bridged");
|
|
assert_eq!(
|
|
response.stop_reason,
|
|
acp_v1::StopReason::Cancelled,
|
|
"{scenario}"
|
|
);
|
|
|
|
acp_thread.read_with(cx, |thread, cx| {
|
|
let compactions = thread
|
|
.entries()
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
acp_thread::AgentThreadEntry::ContextCompaction(compaction) => {
|
|
Some(compaction)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if cancel_before_first_poll {
|
|
assert!(
|
|
compactions.is_empty(),
|
|
"{scenario}: cancellation before the task's first poll must not create a late compaction"
|
|
);
|
|
assert!(!thread.is_compacting(), "{scenario}");
|
|
return;
|
|
}
|
|
|
|
let [compaction] = compactions.as_slice() else {
|
|
panic!("{scenario}: expected exactly one visible compaction");
|
|
};
|
|
assert_eq!(
|
|
compaction.status,
|
|
acp_thread::ContextCompactionStatus::Canceled,
|
|
"{scenario}"
|
|
);
|
|
assert!(!thread.is_compacting(), "{scenario}");
|
|
assert_eq!(
|
|
compaction
|
|
.summary
|
|
.blocks()
|
|
.next()
|
|
.map(|block| block.to_markdown(cx)),
|
|
partial_summary,
|
|
"{scenario}"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_native_tool_names_survive_reused_ids_and_reload(cx: &mut TestAppContext) {
|
|
use language_model::{LanguageModelToolUse, LanguageModelToolUseInput};
|
|
|
|
let fake = init_test(cx);
|
|
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let model = fake.model("thread");
|
|
let fs = project.read_with(cx, |project, _| project.fs().clone());
|
|
fs.write(Path::new("/a/file.txt"), b"retained file contents")
|
|
.await
|
|
.expect("test file should be written");
|
|
cx.update(|cx| {
|
|
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
|
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
|
settings.tool_permissions.tools.clear();
|
|
agent_settings::AgentSettings::override_global(settings, cx);
|
|
thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx));
|
|
});
|
|
|
|
for tool_name in ["read_file", "unavailable_tool"] {
|
|
let prompt_task = cx.update(|cx| {
|
|
acp_thread::AgentSessionClientUserMessageIds::prompt(
|
|
connection.as_ref(),
|
|
ClientUserMessageId::new(),
|
|
acp_v2::PromptRequest::new(session_id.0.clone(), vec!["use a tool".into()]),
|
|
cx,
|
|
)
|
|
});
|
|
cx.run_until_parked();
|
|
let request = fake
|
|
.pending_completions_for(&model)
|
|
.pop()
|
|
.expect("user prompt should reach the model");
|
|
let input = json!({"path": "a/file.txt"});
|
|
fake.send_event(
|
|
&model,
|
|
&request,
|
|
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
|
|
id: "reused_id".into(),
|
|
name: tool_name.into(),
|
|
raw_input: input.to_string(),
|
|
input: LanguageModelToolUseInput::Json(input),
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
}),
|
|
);
|
|
fake.end_stream(&model, &request);
|
|
cx.run_until_parked();
|
|
let request = fake
|
|
.pending_completions_for(&model)
|
|
.pop()
|
|
.expect("tool result should reach the model");
|
|
assert_eq!(request.intent, Some(CompletionIntent::ToolResults));
|
|
fake.send_text(&model, &request, "done");
|
|
fake.end_stream(&model, &request);
|
|
cx.run_until_parked();
|
|
prompt_task.await.expect("native tool turn should complete");
|
|
}
|
|
|
|
let tool_names = |thread: &AcpThread| {
|
|
thread
|
|
.entries()
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
acp_thread::AgentThreadEntry::ToolCall(tool_call) => Some((
|
|
tool_call.id.clone(),
|
|
tool_call.tool_name.clone(),
|
|
std::mem::discriminant(&tool_call.status()),
|
|
)),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let live_tool_names = acp_thread.read_with(cx, |thread, _| tool_names(thread));
|
|
let [
|
|
(read_file_id, read_file_name, read_file_status),
|
|
(unavailable_id, unavailable_name, unavailable_status),
|
|
] = live_tool_names.as_slice()
|
|
else {
|
|
panic!("native turns should produce two distinct ACP tool calls");
|
|
};
|
|
assert_ne!(read_file_id, unavailable_id);
|
|
assert_eq!(read_file_name.as_deref(), Some("read_file"));
|
|
assert_eq!(
|
|
*read_file_status,
|
|
std::mem::discriminant(&acp_thread::ToolCallStatus::Completed)
|
|
);
|
|
assert_eq!(unavailable_name.as_deref(), Some("unavailable_tool"));
|
|
assert_eq!(
|
|
*unavailable_status,
|
|
std::mem::discriminant(&acp_thread::ToolCallStatus::Failed)
|
|
);
|
|
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(!agent.sessions.contains_key(&session_id));
|
|
});
|
|
|
|
let restored = cx
|
|
.update(|cx| {
|
|
connection.clone().load_session(
|
|
session_id,
|
|
project,
|
|
PathList::new(&[Path::new("/a")]),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.expect("native tool session should reload");
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
restored.read_with(cx, |thread, _| tool_names(thread)),
|
|
live_tool_names
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_threads_flushed_to_database_on_app_quit(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
|
|
// A second session whose thread stays empty must be skipped by the
|
|
// quit flush rather than persisted as an empty row.
|
|
let empty_acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let empty_session_id = cx.update(|cx| empty_acp_thread.read(cx).session_id().clone());
|
|
|
|
// Give the first thread content so it's no longer an empty draft, plus
|
|
// an in-progress draft prompt that the flush must capture.
|
|
cx.update(|cx| {
|
|
let path_style = project.read(cx).path_style(cx);
|
|
thread.update(cx, |thread, cx| {
|
|
thread.push_acp_user_block(
|
|
ClientUserMessageId::new(),
|
|
[acp_v1::ContentBlock::from("hello from the user")],
|
|
path_style,
|
|
cx,
|
|
);
|
|
});
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.set_draft_prompt(
|
|
Some(vec![acp_v2::ContentBlock::from("draft in progress")]),
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// Reproduce the orphaned state from the bug: the sidebar metadata and
|
|
// serialized panel still reference the session, but the per-session
|
|
// async content save never landed, so the content row is absent.
|
|
let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap();
|
|
database.delete_thread(session_id.clone()).await.unwrap();
|
|
assert!(
|
|
database
|
|
.load_thread(session_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"precondition: content row should be missing before the quit flush"
|
|
);
|
|
|
|
// Quit through the real shutdown path so the `on_app_quit`
|
|
// registration is exercised, not just the flush itself.
|
|
cx.update(|cx| cx.shutdown());
|
|
|
|
let restored = database
|
|
.load_thread(session_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.expect("thread content should be persisted to the database on quit");
|
|
assert_eq!(
|
|
restored.messages.len(),
|
|
1,
|
|
"the user message should survive the quit flush"
|
|
);
|
|
assert_eq!(
|
|
restored.draft_prompt,
|
|
Some(vec![acp_v2::ContentBlock::from("draft in progress")]),
|
|
"the current draft prompt should be captured by the quit flush"
|
|
);
|
|
assert!(
|
|
database
|
|
.load_thread(empty_session_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"empty threads should not be persisted by the quit flush"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ambiguous_mcp_prompt_names() {
|
|
// Reserving the built-in `/compact` forces a same-named MCP prompt to be
|
|
// server-qualified so it stays reachable; unique names stay bare.
|
|
let ambiguous = ambiguous_mcp_prompt_names([COMPACT_COMMAND_NAME], ["compact", "deploy"]);
|
|
assert!(ambiguous.contains("compact"));
|
|
assert!(!ambiguous.contains("deploy"));
|
|
|
|
// Without the reservation, a unique MCP prompt is left bare.
|
|
let ambiguous = ambiguous_mcp_prompt_names([], ["compact", "deploy"]);
|
|
assert!(ambiguous.is_empty());
|
|
|
|
// Two MCP prompts sharing a name are both qualified regardless of
|
|
// reservation.
|
|
let ambiguous = ambiguous_mcp_prompt_names([], ["dup", "dup", "unique"]);
|
|
assert!(ambiguous.contains("dup"));
|
|
assert!(!ambiguous.contains("unique"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualified_compact_commands_are_not_native_compact() {
|
|
let unqualified_blocks = [acp_v1::ContentBlock::from("/compact")];
|
|
let unqualified = Command::parse(&unqualified_blocks).unwrap();
|
|
assert!(unqualified.is_unqualified("compact"));
|
|
|
|
let mcp_blocks = [acp_v1::ContentBlock::from("/server.compact")];
|
|
let mcp_qualified = Command::parse(&mcp_blocks).unwrap();
|
|
assert_eq!(mcp_qualified.prompt_name, "compact");
|
|
assert_eq!(mcp_qualified.explicit_server_id, Some("server"));
|
|
assert!(!mcp_qualified.is_unqualified("compact"));
|
|
|
|
let skill_blocks = [acp_v1::ContentBlock::from("/:compact")];
|
|
let skill_qualified = Command::parse(&skill_blocks).unwrap();
|
|
assert_eq!(skill_qualified.prompt_name, "compact");
|
|
assert_eq!(skill_qualified.skill_scope, Some(""));
|
|
assert!(!skill_qualified.is_unqualified("compact"));
|
|
}
|
|
|
|
fn make_project_skill(name: &str, description: &str, worktree: &str) -> Skill {
|
|
Skill {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
source: SkillSource::ProjectLocal {
|
|
worktree_id: SkillScopeId(1),
|
|
worktree_root_name: worktree.into(),
|
|
},
|
|
directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")),
|
|
skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
}
|
|
}
|
|
|
|
fn make_builtin_skill(name: &str, description: &str) -> Skill {
|
|
Skill {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
source: SkillSource::BuiltIn,
|
|
directory_path: PathBuf::from(format!("/builtin/{name}")),
|
|
skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: Some("built-in body"),
|
|
}
|
|
}
|
|
|
|
/// Filter to only user-defined (non-built-in) skills for test assertions.
|
|
fn user_skills(skills: &[Skill]) -> Vec<&Skill> {
|
|
skills
|
|
.iter()
|
|
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_combine_skills_keeps_every_entry_for_autocomplete() {
|
|
// The autocomplete popup needs both same-named entries so the
|
|
// source label can disambiguate them. `combine_skills` must not
|
|
// drop the global when a project-local shares its name.
|
|
let global = make_global_skill("review", "Global review");
|
|
let project = make_project_skill("review", "Project review", "project");
|
|
|
|
let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter());
|
|
|
|
assert!(errors.is_empty());
|
|
let user = user_skills(&skills);
|
|
assert_eq!(user.len(), 2);
|
|
assert!(matches!(user[0].source, SkillSource::Global));
|
|
assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_project_wins_over_global() {
|
|
// The model-facing projection collapses the same name to a
|
|
// single entry, with the project-local winning. This is what
|
|
// `select_catalog_skills`, `SkillTool`, and the slash-command
|
|
// resolver all see.
|
|
let global = make_global_skill("review", "Global review");
|
|
let project = make_project_skill("review", "Project review", "project");
|
|
|
|
let resolved = apply_skill_overrides(&[global, project]);
|
|
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].description, "Project review");
|
|
assert!(matches!(
|
|
resolved[0].source,
|
|
SkillSource::ProjectLocal { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_same_source_collision_keeps_first() {
|
|
// Two globals (or two project-locals from different worktrees)
|
|
// colliding don't have a clear winner; preserve the historical
|
|
// "first one wins" behavior.
|
|
let first = make_global_skill("review", "First");
|
|
let second = make_global_skill("review", "Second");
|
|
|
|
let resolved = apply_skill_overrides(&[first, second]);
|
|
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].description, "First");
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_global_wins_over_builtin() {
|
|
// A global skill with the same name as a built-in must shadow
|
|
// the built-in in the model-facing projection, regardless of
|
|
// iteration order.
|
|
let built_in = make_builtin_skill("create-skill", "Built-in version");
|
|
let global = make_global_skill("create-skill", "User override");
|
|
|
|
let resolved = apply_skill_overrides(&[built_in, global]);
|
|
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].description, "User override");
|
|
assert!(matches!(resolved[0].source, SkillSource::Global));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_project_wins_over_builtin() {
|
|
let built_in = make_builtin_skill("create-skill", "Built-in version");
|
|
let project = make_project_skill("create-skill", "Project override", "my-project");
|
|
|
|
let resolved = apply_skill_overrides(&[built_in, project]);
|
|
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].description, "Project override");
|
|
assert!(matches!(
|
|
resolved[0].source,
|
|
SkillSource::ProjectLocal { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_project_wins_over_builtin_and_global() {
|
|
// All three sources present — the project-local must win and
|
|
// both lower-precedence entries must be dropped from the
|
|
// model-facing projection.
|
|
let built_in = make_builtin_skill("create-skill", "Built-in");
|
|
let global = make_global_skill("create-skill", "Global");
|
|
let project = make_project_skill("create-skill", "Project", "my-project");
|
|
|
|
let resolved = apply_skill_overrides(&[built_in, global, project]);
|
|
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].description, "Project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_skill_overrides_preserves_unique_skills() {
|
|
let global_a = make_global_skill("alpha", "a");
|
|
let global_b = make_global_skill("beta", "b");
|
|
let project_c = make_project_skill("gamma", "c", "project");
|
|
|
|
let resolved = apply_skill_overrides(&[global_a, global_b, project_c]);
|
|
|
|
assert_eq!(resolved.len(), 3);
|
|
let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
|
|
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_source_scope_prefix_and_matches_scope() {
|
|
// The popup inserts `/<prefix>:<name>` using `scope_prefix`,
|
|
// and the resolver routes via `matches_scope`. This test pins
|
|
// the contract that the two stay in sync.
|
|
let global = SkillSource::Global;
|
|
// Globals use an empty prefix, so the popup inserts `/:<name>`.
|
|
assert_eq!(global.scope_prefix(), "");
|
|
assert!(global.matches_scope(""));
|
|
// Hand-typed `/global:<name>` is not aliased to the global
|
|
// source; it looks for a worktree literally named `global`.
|
|
assert!(!global.matches_scope("global"));
|
|
assert!(!global.matches_scope("zed"));
|
|
|
|
let project = SkillSource::ProjectLocal {
|
|
worktree_id: SkillScopeId(1),
|
|
worktree_root_name: "zed".into(),
|
|
};
|
|
// Project-local skills are scoped by their worktree root name
|
|
// so multiple open worktrees with same-named skills can each
|
|
// be addressed unambiguously.
|
|
assert_eq!(project.scope_prefix(), "zed");
|
|
assert!(project.matches_scope("zed"));
|
|
// The empty scope is reserved for globals.
|
|
assert!(!project.matches_scope(""));
|
|
// An unrelated worktree name (or MCP server name) must not
|
|
// match a project skill from a different worktree.
|
|
assert!(!project.matches_scope("extensions"));
|
|
|
|
// A worktree literally named `global` is no longer ambiguous
|
|
// with the global source: its skills are invoked as
|
|
// `/global:<name>` while globals are invoked as `/:<name>`.
|
|
let project_named_global = SkillSource::ProjectLocal {
|
|
worktree_id: SkillScopeId(2),
|
|
worktree_root_name: "global".into(),
|
|
};
|
|
assert_eq!(project_named_global.scope_prefix(), "global");
|
|
assert!(project_named_global.matches_scope("global"));
|
|
assert!(!project_named_global.matches_scope(""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_catalog_skills_emits_issue_for_dropped_skills() {
|
|
// Each skill's name + description occupies ~10KB. With a 50KB
|
|
// budget, only the first ~5 visible skills fit; the rest must
|
|
// appear as loading issues so the UI can surface them.
|
|
let description = "x".repeat(10 * 1024);
|
|
let mut skills = Vec::new();
|
|
let total = 10;
|
|
for i in 0..total {
|
|
let name = format!("skill-{i:02}");
|
|
skills.push(Skill {
|
|
name: name.clone(),
|
|
description: description.clone(),
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from(format!("/skills/{name}")),
|
|
skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
});
|
|
}
|
|
|
|
let (kept, issues) = select_catalog_skills(&skills);
|
|
|
|
assert!(
|
|
kept.len() < skills.len(),
|
|
"some skills should be dropped due to the budget (kept {} of {})",
|
|
kept.len(),
|
|
skills.len(),
|
|
);
|
|
assert_eq!(
|
|
issues.len(),
|
|
1,
|
|
"all dropped skills should be consolidated into a single issue, got {issues:?}",
|
|
);
|
|
|
|
let kept_size: usize = kept
|
|
.iter()
|
|
.map(|s| s.name.len() + s.description.len())
|
|
.sum();
|
|
assert!(
|
|
kept_size <= MAX_SKILL_DESCRIPTIONS_SIZE,
|
|
"kept skills must fit in the budget (got {kept_size} bytes)",
|
|
);
|
|
|
|
let issue = &issues[0];
|
|
assert_eq!(issue.kind, SkillLoadingIssueKind::CatalogBudgetExceeded);
|
|
assert!(
|
|
issue.message.contains("50KB") && issue.message.contains("budget"),
|
|
"issue message {:?} should describe the budget",
|
|
issue.message,
|
|
);
|
|
assert_eq!(
|
|
issue.path,
|
|
skills[kept.len()].skill_file_path,
|
|
"issue path should match the first dropped skill",
|
|
);
|
|
|
|
for dropped_skill in &skills[kept.len()..total] {
|
|
let name = &dropped_skill.name;
|
|
assert!(
|
|
issue.message.contains(name.as_str()),
|
|
"issue message {:?} should mention the dropped skill name {name:?}",
|
|
issue.message,
|
|
);
|
|
let bullet_line = format!("- {name}");
|
|
assert!(
|
|
issue
|
|
.message
|
|
.lines()
|
|
.any(|line| line.starts_with(&bullet_line)),
|
|
"issue message {:?} should contain a bullet line starting with {bullet_line:?}",
|
|
issue.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_catalog_skills_stops_packing_after_first_overflow() {
|
|
// Once a model-invocable skill overflows the budget, no later
|
|
// skills should be admitted, even if they're small enough to fit
|
|
// in the remaining sliver. This keeps the cutoff deterministic by
|
|
// sort order rather than dependent on individual skill sizes.
|
|
let half_description = "a".repeat(MAX_SKILL_DESCRIPTIONS_SIZE / 2);
|
|
let big_description = "b".repeat(MAX_SKILL_DESCRIPTIONS_SIZE);
|
|
let small_description = "c".repeat(100);
|
|
|
|
let first = Skill {
|
|
name: "skill-01-first".to_string(),
|
|
description: half_description,
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from("/skills/skill-01-first"),
|
|
skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
};
|
|
let second = Skill {
|
|
name: "skill-02-overflows".to_string(),
|
|
description: big_description,
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from("/skills/skill-02-overflows"),
|
|
skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
};
|
|
let third = Skill {
|
|
name: "skill-03-would-fit".to_string(),
|
|
description: small_description,
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from("/skills/skill-03-would-fit"),
|
|
skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
};
|
|
|
|
// Sanity-check the test setup: the third skill is small enough
|
|
// that a greedy packer would have squeezed it in alongside the
|
|
// first one.
|
|
let leftover_after_first =
|
|
MAX_SKILL_DESCRIPTIONS_SIZE - (first.name.len() + first.description.len());
|
|
assert!(
|
|
third.name.len() + third.description.len() <= leftover_after_first,
|
|
"third skill must fit in the leftover sliver for this test to be meaningful",
|
|
);
|
|
|
|
let skills = vec![first.clone(), second.clone(), third.clone()];
|
|
let (kept, issues) = select_catalog_skills(&skills);
|
|
|
|
let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
|
|
assert_eq!(kept_names, vec![first.name.as_str()]);
|
|
|
|
assert_eq!(issues.len(), 1, "expected a single consolidated issue");
|
|
assert_eq!(issues[0].kind, SkillLoadingIssueKind::CatalogBudgetExceeded);
|
|
assert_eq!(issues[0].path, second.skill_file_path);
|
|
assert!(
|
|
issues[0].message.contains(second.name.as_str()),
|
|
"issue message {:?} should mention {:?}",
|
|
issues[0].message,
|
|
second.name,
|
|
);
|
|
assert!(
|
|
issues[0].message.contains(third.name.as_str()),
|
|
"issue message {:?} should mention {:?}",
|
|
issues[0].message,
|
|
third.name,
|
|
);
|
|
assert!(
|
|
issues[0].message.contains("- "),
|
|
"issue message {:?} should use bullet form when multiple skills are dropped",
|
|
issues[0].message,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_catalog_skills_excludes_hidden_skills_from_catalog() {
|
|
// Hidden skills (`disable_model_invocation: true`) are slash-only and
|
|
// must not appear in the catalog returned by `select_catalog_skills`,
|
|
// even when they would otherwise fit in the budget. They also don't
|
|
// count against the budget, so a hidden skill larger than the entire
|
|
// budget shouldn't generate a loading issue or prevent later visible
|
|
// skills from fitting.
|
|
let huge_description = "y".repeat(MAX_SKILL_DESCRIPTIONS_SIZE * 2);
|
|
let hidden = Skill {
|
|
name: "hidden-huge".to_string(),
|
|
description: huge_description,
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from("/skills/hidden-huge"),
|
|
skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: true,
|
|
embedded_body: None,
|
|
};
|
|
let visible = Skill {
|
|
name: "visible".to_string(),
|
|
description: "short".to_string(),
|
|
source: SkillSource::Global,
|
|
directory_path: PathBuf::from("/skills/visible"),
|
|
skill_file_path: PathBuf::from("/skills/visible/SKILL.md"),
|
|
load_warnings: Vec::new(),
|
|
disable_model_invocation: false,
|
|
embedded_body: None,
|
|
};
|
|
|
|
let (kept, issues) = select_catalog_skills(&[hidden, visible]);
|
|
|
|
assert!(issues.is_empty(), "expected no issues, got: {issues:?}");
|
|
let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
|
|
assert_eq!(kept_names, vec!["visible"]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_maintaining_project_context(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
// Creating a session registers the project and triggers context building.
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let thread = agent.read_with(cx, |agent, _cx| {
|
|
agent.sessions.values().next().unwrap().thread.clone()
|
|
});
|
|
|
|
agent.read_with(cx, |agent, cx| {
|
|
let project_id = project.entity_id();
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
assert_eq!(state.project_context.read(cx).worktrees, vec![]);
|
|
assert_eq!(thread.read(cx).project_context().read(cx).worktrees, vec![]);
|
|
});
|
|
|
|
let worktree = project
|
|
.update(cx, |project, cx| project.create_worktree("/a", true, cx))
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
agent.read_with(cx, |agent, cx| {
|
|
let project_id = project.entity_id();
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let expected_worktrees = vec![WorktreeContext {
|
|
root_name: "a".into(),
|
|
abs_path: Path::new("/a").into(),
|
|
rules_file: None,
|
|
}];
|
|
assert_eq!(state.project_context.read(cx).worktrees, expected_worktrees);
|
|
assert_eq!(
|
|
thread.read(cx).project_context().read(cx).worktrees,
|
|
expected_worktrees
|
|
);
|
|
});
|
|
|
|
// Creating `/a/.rules` updates the project context.
|
|
fs.insert_file("/a/.rules", Vec::new()).await;
|
|
cx.run_until_parked();
|
|
agent.read_with(cx, |agent, cx| {
|
|
let project_id = project.entity_id();
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let rules_entry = worktree
|
|
.read(cx)
|
|
.entry_for_path(rel_path(".rules"))
|
|
.unwrap();
|
|
let expected_worktrees = vec![WorktreeContext {
|
|
root_name: "a".into(),
|
|
abs_path: Path::new("/a").into(),
|
|
rules_file: Some(RulesFileContext {
|
|
path_in_worktree: rel_path(".rules").into(),
|
|
text: "".into(),
|
|
project_entry_id: rules_entry.id.to_usize(),
|
|
}),
|
|
}];
|
|
assert_eq!(state.project_context.read(cx).worktrees, expected_worktrees);
|
|
assert_eq!(
|
|
thread.read(cx).project_context().read(cx).worktrees,
|
|
expected_worktrees
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_global_skills_load_and_reload(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
let initial_skill_dir = skills_dir.join("my-skill");
|
|
let initial_skill_path = initial_skill_dir.join("SKILL.md");
|
|
fs.create_dir(&initial_skill_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&initial_skill_path,
|
|
b"---\nname: my-skill\ndescription: First version\n---\n\nbody-v1".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
// Simulate the user-interaction trigger that the agent panel
|
|
// fires (input focus, slash autocomplete, or submit). In tests
|
|
// we call it directly because there's no panel.
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// The pre-existing skill should be loaded into the project state.
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project.entity_id()).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "my-skill");
|
|
assert_eq!(user[0].description, "First version");
|
|
});
|
|
|
|
// Modify the SKILL.md and verify the project context refreshes.
|
|
fs.write(
|
|
&initial_skill_path,
|
|
b"---\nname: my-skill\ndescription: Second version\n---\n\nbody-v2",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project.entity_id()).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].description, "Second version");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_global_skill_with_long_description_loads_with_warning(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
let skill_dir = skills_dir.join("long-description");
|
|
let skill_path = skill_dir.join("SKILL.md");
|
|
let long_description = "a".repeat(agent_skills::MAX_SKILL_DESCRIPTION_LEN + 1);
|
|
fs.create_dir(&skill_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&skill_path,
|
|
format!("---\nname: long-description\ndescription: {long_description}\n---\n\nbody")
|
|
.into_bytes(),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let project_id = project.entity_id();
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let loaded_skill = agent.read_with(cx, |agent, cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "long-description");
|
|
assert_eq!(user[0].description, long_description);
|
|
|
|
let catalog_names: Vec<&str> = state
|
|
.project_context
|
|
.read(cx)
|
|
.skills()
|
|
.iter()
|
|
.map(|skill| skill.name.as_str())
|
|
.collect();
|
|
assert!(
|
|
catalog_names.contains(&"long-description"),
|
|
"long-description skill should remain in the model catalog: {catalog_names:?}"
|
|
);
|
|
|
|
assert!(
|
|
state.skill_loading_issues.iter().any(|issue| {
|
|
issue.kind == SkillLoadingIssueKind::DescriptionTooLong
|
|
&& issue.path == skill_path
|
|
&& issue.message.to_string().contains("1024-character limit")
|
|
}),
|
|
"expected a description-length warning issue, got {:?}",
|
|
state.skill_loading_issues
|
|
);
|
|
|
|
(*user[0]).clone()
|
|
});
|
|
|
|
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
|
|
cx.update(|cx| {
|
|
let available_skills = connection.available_skills(&session_id, cx);
|
|
let available_skill = available_skills
|
|
.iter()
|
|
.find(|skill| skill.name == "long-description")
|
|
.expect("long-description should appear in available skills");
|
|
assert_eq!(available_skill.description, long_description);
|
|
assert!(
|
|
available_skill
|
|
.warning
|
|
.as_ref()
|
|
.is_some_and(|warning| warning.contains("1024-character limit")),
|
|
"available skill should expose warning text, got {:?}",
|
|
available_skill.warning
|
|
);
|
|
});
|
|
|
|
let body = agent_skills::read_skill_body(fs.as_ref(), &loaded_skill.skill_file_path)
|
|
.await
|
|
.expect("body should load despite description-length warning");
|
|
assert_eq!(body, "body");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_symlinked_global_skills_load_and_reload(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
let external_skill_dir = PathBuf::from(path!("/external/my-skill"));
|
|
let skill_link_dir = skills_dir.join("my-skill");
|
|
let skill_link_path = skill_link_dir.join("SKILL.md");
|
|
|
|
fs.insert_tree(
|
|
&external_skill_dir,
|
|
json!({
|
|
"SKILL.md": "---\nname: my-skill\ndescription: First symlinked version\n---\n\nbody-v1"
|
|
}),
|
|
)
|
|
.await;
|
|
fs.create_dir(&skills_dir).await.unwrap();
|
|
fs.create_symlink(&skill_link_dir, external_skill_dir)
|
|
.await
|
|
.unwrap();
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let project_id = project.entity_id();
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let loaded_skill = agent.read_with(cx, |agent, cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "my-skill");
|
|
assert_eq!(user[0].description, "First symlinked version");
|
|
assert_eq!(user[0].source, SkillSource::Global);
|
|
assert_eq!(user[0].skill_file_path, skill_link_path);
|
|
|
|
let catalog_skills = state.project_context.read(cx).skills();
|
|
let catalog_skill = catalog_skills
|
|
.iter()
|
|
.find(|skill| skill.name == "my-skill")
|
|
.expect("symlinked skill should be included in the model-facing catalog");
|
|
assert_eq!(catalog_skill.description, "First symlinked version");
|
|
assert_eq!(
|
|
catalog_skill.location,
|
|
skill_link_path.to_string_lossy().as_ref()
|
|
);
|
|
|
|
(*user[0]).clone()
|
|
});
|
|
let body = agent_skills::read_skill_body(fs.as_ref(), &loaded_skill.skill_file_path)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body, "body-v1");
|
|
|
|
fs.write(
|
|
&skill_link_path,
|
|
b"---\nname: my-skill\ndescription: Second symlinked version\n---\n\nbody-v2",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let reloaded_skill = agent.read_with(cx, |agent, cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "my-skill");
|
|
assert_eq!(user[0].description, "Second symlinked version");
|
|
assert_eq!(user[0].source, SkillSource::Global);
|
|
assert_eq!(user[0].skill_file_path, skill_link_path);
|
|
|
|
let catalog_skills = state.project_context.read(cx).skills();
|
|
let catalog_skill = catalog_skills
|
|
.iter()
|
|
.find(|skill| skill.name == "my-skill")
|
|
.expect("reloaded symlinked skill should be included in the model-facing catalog");
|
|
assert_eq!(catalog_skill.description, "Second symlinked version");
|
|
assert_eq!(
|
|
catalog_skill.location,
|
|
skill_link_path.to_string_lossy().as_ref()
|
|
);
|
|
|
|
(*user[0]).clone()
|
|
});
|
|
let body = agent_skills::read_skill_body(fs.as_ref(), &reloaded_skill.skill_file_path)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body, "body-v2");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_global_skills_dir_created_after_startup(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
|
|
// Intentionally do NOT pre-create `skills_dir`. The first scan
|
|
// trigger should find no directory and leave the watch state
|
|
// idle; a later trigger after the directory is created should
|
|
// attach to the deepest existing ancestor and react when the
|
|
// directory is created later.
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
// First scan trigger: nothing on disk yet, state stays idle.
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// No skills directory exists yet, so no skills should be loaded.
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project.entity_id()).unwrap();
|
|
assert!(
|
|
user_skills(&state.skills).is_empty(),
|
|
"expected no user skills before the global skills dir exists, got {:?}",
|
|
state.skills
|
|
);
|
|
});
|
|
|
|
// Create the global skills directory and a skill within it.
|
|
let new_skill_dir = skills_dir.join("late-skill");
|
|
fs.create_dir(&new_skill_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&new_skill_dir.join("SKILL.md"),
|
|
b"---\nname: late-skill\ndescription: Created after startup\n---\n\nbody".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
// Fire the trigger again, simulating the user interacting with
|
|
// the agent panel after creating the skills directory. The
|
|
// second scan should find the directory and start the watch,
|
|
// which refreshes project context.
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project.entity_id()).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "late-skill");
|
|
assert_eq!(user[0].description, "Created after startup");
|
|
});
|
|
}
|
|
|
|
/// Regression test for the case where a skill is added (e.g. by the
|
|
/// SKILL.md file watcher) AFTER a session is registered. The system
|
|
/// prompt and slash-command list both read live state, so they pick
|
|
/// up the new skill automatically. The `SkillTool` registered on the
|
|
/// thread used to hold a stale snapshot of `state.skills` taken at
|
|
/// thread-construction time, which meant the model would see the new
|
|
/// skill in `<available_skills>` but get "not found" when it tried to
|
|
/// invoke it. The fix wires the tool to a dynamic resolver closure
|
|
/// that re-reads `state.skills` for the project on every invocation.
|
|
#[gpui::test]
|
|
async fn test_skills_added_after_session_visible_to_skill_tool(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
|
|
// No skills directory exists at startup; the watcher should
|
|
// create one and pick up SKILL.md when it's added later.
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
// First scan trigger: nothing on disk yet.
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let project_id = project.entity_id();
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
assert!(
|
|
user_skills(&state.skills).is_empty(),
|
|
"expected no user skills before the global skills dir exists, got {:?}",
|
|
state.skills
|
|
);
|
|
});
|
|
|
|
// Build the same resolver closure that `register_session` uses.
|
|
// This is the production resolver factored into a helper so the
|
|
// test can verify resolution behavior directly without setting
|
|
// up the full tool-call plumbing (`ToolInput`,
|
|
// `ToolCallEventStream`, authorization channel, ...).
|
|
let resolve =
|
|
cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
|
|
|
|
// Sanity check: before any skills exist, the resolver returns an
|
|
// empty list — NOT the snapshot that `Thread::new` would have
|
|
// captured.
|
|
cx.update(|cx| {
|
|
let all = resolve(cx);
|
|
let user: Vec<_> = all
|
|
.iter()
|
|
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
|
.collect();
|
|
assert!(user.is_empty());
|
|
});
|
|
|
|
// Now create a SKILL.md AFTER the session was registered. With
|
|
// the old code this would be invisible to the `SkillTool`
|
|
// because the tool held an `Arc<Vec<Skill>>` snapshot taken at
|
|
// thread construction time.
|
|
let new_skill_dir = skills_dir.join("my-skill");
|
|
fs.create_dir(&new_skill_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&new_skill_dir.join("SKILL.md"),
|
|
b"---\nname: my-skill\ndescription: Created after session\n---\n\nbody".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
// Second scan trigger: now the directory exists, so the scan
|
|
// starts the watch and refreshes project context.
|
|
cx.update(|cx| {
|
|
agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// `state.skills` reflects the new skill (the watcher ran).
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
assert_eq!(user.len(), 1);
|
|
assert_eq!(user[0].name, "my-skill");
|
|
});
|
|
|
|
// The resolver the `SkillTool` uses must see it too. This is the
|
|
// crux of the regression test: the tool's view of skills is
|
|
// resolved at invocation time, not at thread-construction time.
|
|
cx.update(|cx| {
|
|
let all = resolve(cx);
|
|
let snapshot: Vec<_> = all
|
|
.iter()
|
|
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
|
.collect();
|
|
assert_eq!(
|
|
snapshot.len(),
|
|
1,
|
|
"dynamic resolver should see the new skill"
|
|
);
|
|
assert_eq!(snapshot[0].name, "my-skill");
|
|
assert_eq!(snapshot[0].description, "Created after session");
|
|
});
|
|
|
|
// And rendering the envelope through the same path the tool uses
|
|
// produces a `<skill_content name="my-skill">` block, confirming
|
|
// the model would see the new skill if it invoked the tool.
|
|
let skill_for_render = cx.update(|cx| {
|
|
let snapshot = resolve(cx);
|
|
snapshot
|
|
.iter()
|
|
.find(|s| s.name == "my-skill" && !s.disable_model_invocation)
|
|
.cloned()
|
|
.expect("my-skill should be model-invocable")
|
|
});
|
|
let body = agent_skills::read_skill_body(fs.as_ref(), &skill_for_render.skill_file_path)
|
|
.await
|
|
.expect("skill body should load");
|
|
let rendered = render_skill_envelope(&skill_for_render, &body);
|
|
assert!(
|
|
rendered.contains("<skill_content name=\"my-skill\">"),
|
|
"rendered envelope missing skill_content tag: {rendered}"
|
|
);
|
|
}
|
|
|
|
/// Subagents must inherit access to the same skills as their parent.
|
|
/// Production wires this up in `NativeThreadEnvironment::create_subagent_thread`,
|
|
/// which calls `agent.register_session(subagent, project_id, ...)` —
|
|
/// `register_session` is what installs the `SkillTool` on the thread
|
|
/// using a resolver closure keyed on `project_id`. Because the
|
|
/// subagent shares its parent's `project_id`, both threads end up
|
|
/// resolving skills against the same `state.skills`.
|
|
///
|
|
/// This test exercises that production path directly: it creates a
|
|
/// parent session via the agent connection, builds a subagent thread
|
|
/// the same way `create_subagent_thread` does, and runs it through
|
|
/// `register_session`. It then asserts that the `SkillTool` is
|
|
/// registered on the subagent thread and that resolving against the
|
|
/// same `project_id` produces the same skill set the parent sees.
|
|
#[gpui::test]
|
|
async fn test_subagent_skills_lookup_matches_parent(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
let skill_dir = skills_dir.join("shared-skill");
|
|
fs.create_dir(&skill_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&skill_dir.join("SKILL.md"),
|
|
b"---\nname: shared-skill\ndescription: A shared skill\n---\n\nbody".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
// Open a parent session through the connection, the same way
|
|
// production does. This triggers project-context refresh which
|
|
// populates `state.skills` for the project.
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let _parent_acp = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let project_id = project.entity_id();
|
|
|
|
// Sanity check: resolving against the parent's project sees the skill.
|
|
let parent_resolve =
|
|
cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
|
|
cx.update(|cx| {
|
|
let all = parent_resolve(cx);
|
|
let parent_skills: Vec<_> = all
|
|
.iter()
|
|
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
|
.collect();
|
|
assert_eq!(parent_skills.len(), 1);
|
|
assert_eq!(parent_skills[0].name, "shared-skill");
|
|
});
|
|
|
|
// Grab the parent thread out of the agent's session map. This
|
|
// mirrors what `create_subagent_thread` does internally — it
|
|
// looks up the parent session by `parent_session_id` and reads
|
|
// its `project_id` to forward to `register_session`.
|
|
let (parent_thread, parent_project_id) = agent.read_with(cx, |agent, _cx| {
|
|
let session = agent
|
|
.sessions
|
|
.values()
|
|
.next()
|
|
.expect("parent session should exist");
|
|
(session.thread.clone(), session.project_id)
|
|
});
|
|
assert_eq!(parent_project_id, project_id);
|
|
|
|
// Build the subagent thread the same way
|
|
// `NativeThreadEnvironment::create_subagent_thread` does.
|
|
let subagent_thread =
|
|
cx.update(|cx| cx.new(|cx| Thread::new_subagent(&parent_thread, None, cx)));
|
|
|
|
// Run the subagent through the production registration path.
|
|
// This is what installs the `SkillTool` on the thread.
|
|
let _subagent_acp = agent.update(cx, |agent, cx| {
|
|
agent.register_session(subagent_thread.clone(), parent_project_id, cx)
|
|
});
|
|
|
|
// Verify the subagent thread has the `SkillTool` installed —
|
|
// without `register_session`, it would not.
|
|
subagent_thread.read_with(cx, |thread, _cx| {
|
|
assert!(thread.is_subagent());
|
|
assert!(
|
|
thread.has_registered_tool(SkillTool::NAME),
|
|
"subagent should have SkillTool registered after register_session"
|
|
);
|
|
});
|
|
|
|
// The subagent's `SkillTool` is wired to a resolver closure keyed
|
|
// on the same `project_id` the parent used, so it sees the same
|
|
// skill set. We check this by constructing an equivalent resolver
|
|
// against the same project_id and asserting it matches.
|
|
let subagent_resolve = cx
|
|
.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id));
|
|
cx.update(|cx| {
|
|
let all = subagent_resolve(cx);
|
|
let subagent_skills: Vec<_> = all
|
|
.iter()
|
|
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
|
.collect();
|
|
assert_eq!(subagent_skills.len(), 1);
|
|
assert_eq!(subagent_skills[0].name, "shared-skill");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let skills_dir = global_skills_dir();
|
|
|
|
// Two skills: one model-invocable (default), one slash-only via
|
|
// `disable-model-invocation: true`. Both should still appear in
|
|
// the slash menu as first-class skills.
|
|
let visible_dir = skills_dir.join("visible-skill");
|
|
fs.create_dir(&visible_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&visible_dir.join("SKILL.md"),
|
|
b"---\nname: visible-skill\ndescription: Visible skill\n---\n\nbody".to_vec(),
|
|
)
|
|
.await;
|
|
|
|
let hidden_dir = skills_dir.join("deploy");
|
|
fs.create_dir(&hidden_dir).await.unwrap();
|
|
fs.insert_file(
|
|
&hidden_dir.join("SKILL.md"),
|
|
b"---\nname: deploy\ndescription: Deploy to prod\ndisable-model-invocation: true\n---\n\nbody"
|
|
.to_vec(),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let project_id = project.entity_id();
|
|
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
|
|
|
|
acp_thread.read_with(cx, |thread, _cx| {
|
|
let commands = thread.available_commands();
|
|
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
|
|
assert!(
|
|
!names.contains(&"visible-skill"),
|
|
"skills should not be exposed as ACP slash commands: {names:?}"
|
|
);
|
|
assert!(
|
|
!names.contains(&"deploy"),
|
|
"slash-only skills should not be exposed as ACP slash commands: {names:?}"
|
|
);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let skills = connection.available_skills(&session_id, cx);
|
|
let names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
|
|
assert!(
|
|
names.contains(&"visible-skill"),
|
|
"visible skill missing from available skills: {names:?}"
|
|
);
|
|
assert!(
|
|
names.contains(&"deploy"),
|
|
"slash-only skill missing from available skills: {names:?}"
|
|
);
|
|
});
|
|
|
|
// The model's catalog (ProjectContext.skills) should NOT include
|
|
// `deploy` since it has disable_model_invocation set.
|
|
agent.read_with(cx, |agent, cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let catalog: Vec<&str> = state
|
|
.project_context
|
|
.read(cx)
|
|
.skills()
|
|
.iter()
|
|
.map(|s| s.name.as_str())
|
|
.collect();
|
|
assert!(
|
|
catalog.contains(&"visible-skill"),
|
|
"visible skill missing from catalog: {catalog:?}"
|
|
);
|
|
assert!(
|
|
!catalog.contains(&"deploy"),
|
|
"deploy should be excluded from catalog: {catalog:?}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_project_skills_require_worktree_trust(cx: &mut TestAppContext) {
|
|
use collections::{HashMap, HashSet};
|
|
use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees};
|
|
|
|
init_test(cx);
|
|
cx.update(|cx| {
|
|
// The trust global isn't created by `init_test`. We need it
|
|
// for `Project::test_with_worktree_trust` to actually wire up
|
|
// trust tracking and for our subscription in
|
|
// `register_project_with_initial_context` to fire.
|
|
trusted_worktrees::init(HashMap::default(), cx);
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": {
|
|
"skills": {
|
|
"my-skill": {
|
|
"SKILL.md": "---\nname: my-skill\ndescription: A project skill\n---\n\nbody"
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// `test_with_worktree_trust` initializes the trust system and
|
|
// starts every worktree as restricted, mirroring production
|
|
// behavior on a freshly opened folder.
|
|
let project =
|
|
Project::test_with_worktree_trust(fs.clone(), [Path::new("/project")], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/project")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let project_id = project.entity_id();
|
|
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
|
|
let worktree_id = project.read_with(cx, |project, cx| {
|
|
project.worktrees(cx).next().unwrap().read(cx).id()
|
|
});
|
|
|
|
// Untrusted: project skills are excluded from the loaded list and
|
|
// never make it into the catalog or slash commands.
|
|
agent.read_with(cx, |agent, cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
assert!(
|
|
user_skills(&state.skills).is_empty(),
|
|
"untrusted worktree skills should not load: {:?}",
|
|
state
|
|
.skills
|
|
.iter()
|
|
.map(|s| s.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
let commands = acp_thread.read(cx).available_commands();
|
|
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
|
|
assert!(
|
|
!names.contains(&"my-skill"),
|
|
"untrusted skill leaked into slash commands: {names:?}"
|
|
);
|
|
});
|
|
|
|
// Granting trust should trigger a context refresh; the skill then
|
|
// appears in both the catalog and the slash-command list.
|
|
cx.update(|cx| {
|
|
let trusted_worktrees = TrustedWorktrees::try_get_global(cx)
|
|
.expect("trusted worktrees global initialized by test_with_worktree_trust");
|
|
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
|
trusted_worktrees.trust(
|
|
&project.read(cx).worktree_store(),
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let user = user_skills(&state.skills);
|
|
let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect();
|
|
assert_eq!(names, vec!["my-skill"]);
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let skills = connection.available_skills(&session_id, cx);
|
|
let skill_names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
|
assert!(
|
|
skill_names.contains(&"my-skill"),
|
|
"trusted skill should appear in available skills: {skill_names:?}"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Open a session against a freshly created project and trust its only
|
|
/// worktree, so project-local skills load. Returns the agent, the
|
|
/// project, and the worktree id of the project root.
|
|
async fn open_trusted_project_skills(
|
|
cx: &mut TestAppContext,
|
|
fs: Arc<FakeFs>,
|
|
root: &str,
|
|
) -> (
|
|
Entity<NativeAgent>,
|
|
Entity<Project>,
|
|
WorktreeId,
|
|
Entity<AcpThread>,
|
|
) {
|
|
use collections::{HashMap, HashSet};
|
|
use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees};
|
|
|
|
cx.update(|cx| {
|
|
trusted_worktrees::init(HashMap::default(), cx);
|
|
});
|
|
|
|
let project = Project::test_with_worktree_trust(fs.clone(), [Path::new(root)], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new(root)]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let worktree_id = project.read_with(cx, |project, cx| {
|
|
project.worktrees(cx).next().unwrap().read(cx).id()
|
|
});
|
|
cx.update(|cx| {
|
|
let trusted_worktrees = TrustedWorktrees::try_get_global(cx)
|
|
.expect("trusted worktrees global initialized by test_with_worktree_trust");
|
|
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
|
trusted_worktrees.trust(
|
|
&project.read(cx).worktree_store(),
|
|
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
(agent, project, worktree_id, acp_thread)
|
|
}
|
|
|
|
/// The body resolver for a project-local skill must read the file
|
|
/// through a project buffer rather than the local filesystem. This is
|
|
/// what makes project skills resolvable in remote workspaces, where
|
|
/// the `fs` the agent holds is the client's filesystem and not where
|
|
/// the project files actually live. We prove the buffer path is used
|
|
/// by editing the buffer in memory (without saving) and asserting the
|
|
/// resolver returns the edited body, not the on-disk body.
|
|
#[gpui::test]
|
|
async fn test_project_skill_body_resolves_through_buffer(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": {
|
|
"skills": {
|
|
"my-skill": {
|
|
"SKILL.md": "---\nname: my-skill\ndescription: A project skill\n---\n\ndisk body"
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (agent, project, worktree_id, _acp_thread) =
|
|
open_trusted_project_skills(cx, fs.clone(), "/project").await;
|
|
let project_id = project.entity_id();
|
|
|
|
let skill = agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
user_skills(&state.skills)
|
|
.into_iter()
|
|
.find(|s| s.name == "my-skill")
|
|
.cloned()
|
|
.expect("project skill should be loaded")
|
|
});
|
|
assert!(matches!(skill.source, SkillSource::ProjectLocal { .. }));
|
|
|
|
let resolver =
|
|
cx.update(|_cx| super::skill_body_resolver_for_project(project.clone(), fs.clone()));
|
|
|
|
let body = cx
|
|
.update(|cx| resolver(skill.clone(), &mut cx.to_async()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body, "disk body");
|
|
|
|
// Edit the buffer in memory without writing to disk.
|
|
let relative_path: Arc<RelPath> = rel_path(".agents/skills/my-skill/SKILL.md").into();
|
|
let buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer((worktree_id, relative_path), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.set_text(
|
|
"---\nname: my-skill\ndescription: A project skill\n---\n\nedited body",
|
|
cx,
|
|
);
|
|
});
|
|
|
|
let body = cx
|
|
.update(|cx| resolver(skill.clone(), &mut cx.to_async()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
body, "edited body",
|
|
"resolver must read the in-memory buffer, not the on-disk file"
|
|
);
|
|
}
|
|
|
|
/// A project SKILL.md whose on-disk size exceeds the cap must be
|
|
/// rejected with a size-limit error and excluded from the loaded
|
|
/// skills, exercising the size guard in `load_project_skills`.
|
|
#[gpui::test]
|
|
async fn test_oversized_project_skill_reports_error(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
let oversized = format!(
|
|
"---\nname: huge-skill\ndescription: Too big\n---\n\n{}",
|
|
"a".repeat(MAX_SKILL_FILE_SIZE + 1)
|
|
);
|
|
fs.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": { "skills": { "huge-skill": { "SKILL.md": oversized } } }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (agent, project, _worktree_id, _acp_thread) =
|
|
open_trusted_project_skills(cx, fs.clone(), "/project").await;
|
|
let project_id = project.entity_id();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
assert!(
|
|
user_skills(&state.skills).is_empty(),
|
|
"oversized skill must not load: {:?}",
|
|
user_skills(&state.skills)
|
|
.iter()
|
|
.map(|s| s.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert!(
|
|
state
|
|
.skill_loading_issues
|
|
.iter()
|
|
.any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed
|
|
&& issue.message.to_string().contains("maximum size")),
|
|
"expected a size-limit error, got {:?}",
|
|
state.skill_loading_issues
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A malformed project SKILL.md must surface a per-skill load error
|
|
/// without preventing sibling skills in the same worktree from
|
|
/// loading.
|
|
#[gpui::test]
|
|
async fn test_malformed_project_skill_reports_error(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": {
|
|
"skills": {
|
|
"good": {
|
|
"SKILL.md": "---\nname: good\ndescription: Fine\n---\n\nbody"
|
|
},
|
|
"bad": {
|
|
"SKILL.md": "this file has no frontmatter"
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (agent, project, _worktree_id, _acp_thread) =
|
|
open_trusted_project_skills(cx, fs.clone(), "/project").await;
|
|
let project_id = project.entity_id();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let names: Vec<&str> = user_skills(&state.skills)
|
|
.iter()
|
|
.map(|s| s.name.as_str())
|
|
.collect();
|
|
assert_eq!(names, vec!["good"], "only the valid skill should load");
|
|
assert!(
|
|
state
|
|
.skill_loading_issues
|
|
.iter()
|
|
.any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed
|
|
&& issue.path.ends_with("bad/SKILL.md")),
|
|
"expected an error for the malformed skill, got {:?}",
|
|
state.skill_loading_issues
|
|
);
|
|
});
|
|
}
|
|
|
|
/// The skill catalog (metadata) is also loaded through project
|
|
/// buffers, and the broadened `.agents` refresh trigger must rebuild
|
|
/// it when files under `.agents` change. We edit the SKILL.md buffer
|
|
/// in memory, then touch an unrelated file directly under `.agents`
|
|
/// (not under `.agents/skills`) and assert the catalog reflects the
|
|
/// in-memory edit. Under the previous `.agents/skills`-only trigger
|
|
/// this refresh would not have fired.
|
|
#[gpui::test]
|
|
async fn test_project_skill_metadata_refreshes_from_buffer(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": {
|
|
"skills": {
|
|
"my-skill": {
|
|
"SKILL.md": "---\nname: my-skill\ndescription: Original\n---\n\nbody"
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (agent, project, worktree_id, _acp_thread) =
|
|
open_trusted_project_skills(cx, fs.clone(), "/project").await;
|
|
let project_id = project.entity_id();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let skill = user_skills(&state.skills)
|
|
.into_iter()
|
|
.find(|s| s.name == "my-skill")
|
|
.expect("skill should be loaded");
|
|
assert_eq!(skill.description, "Original");
|
|
});
|
|
|
|
let relative_path: Arc<RelPath> = rel_path(".agents/skills/my-skill/SKILL.md").into();
|
|
let buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer((worktree_id, relative_path), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.set_text(
|
|
"---\nname: my-skill\ndescription: Edited in buffer\n---\n\nbody",
|
|
cx,
|
|
);
|
|
});
|
|
|
|
// Touch a file directly under `.agents` (not under
|
|
// `.agents/skills`) to trigger the broadened refresh path.
|
|
fs.insert_file("/project/.agents/marker.txt", b"hello".to_vec())
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
agent.read_with(cx, |agent, _cx| {
|
|
let state = agent.projects.get(&project_id).unwrap();
|
|
let skill = user_skills(&state.skills)
|
|
.into_iter()
|
|
.find(|s| s.name == "my-skill")
|
|
.expect("skill should still be loaded");
|
|
assert_eq!(
|
|
skill.description, "Edited in buffer",
|
|
"catalog must reflect the in-memory buffer after a refresh"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_listing_models(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let connection = NativeAgentConnection(
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)),
|
|
);
|
|
|
|
// Create a thread/session
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
|
|
let models = cx
|
|
.update(|cx| {
|
|
connection
|
|
.model_selector(&session_id)
|
|
.unwrap()
|
|
.list_models(cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let acp_thread::AgentModelList::Grouped(models) = models else {
|
|
panic!("Unexpected model group");
|
|
};
|
|
assert_eq!(
|
|
models,
|
|
IndexMap::from_iter([(
|
|
AgentModelGroupName("Fake".into()),
|
|
vec![AgentModelInfo {
|
|
id: AgentModelId::new("fake/fake"),
|
|
name: "Fake".into(),
|
|
description: None,
|
|
icon: Some(acp_thread::AgentModelIcon::Named(
|
|
ui::IconName::ZedAssistant
|
|
)),
|
|
is_latest: false,
|
|
disabled: None,
|
|
cost: None,
|
|
}]
|
|
)])
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.create_dir(paths::settings_file().parent().unwrap())
|
|
.await
|
|
.unwrap();
|
|
fs.insert_file(
|
|
paths::settings_file(),
|
|
json!({
|
|
"agent": {
|
|
"default_model": {
|
|
"provider": "foo",
|
|
"model": "bar"
|
|
}
|
|
}
|
|
})
|
|
.to_string()
|
|
.into_bytes(),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
|
|
// Create the agent and connection
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
|
|
// Create a thread/session
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
|
|
// Select a model
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
let model_id = AgentModelId::new("fake/fake");
|
|
cx.update(|cx| selector.select_model(model_id.clone(), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify the thread has the selected model
|
|
agent.read_with(cx, |agent, _| {
|
|
let session = agent.sessions.get(&session_id).unwrap();
|
|
session.thread.read_with(cx, |thread, _| {
|
|
assert_eq!(thread.model().unwrap().id().0, "fake");
|
|
});
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
// Verify settings file was updated
|
|
let settings_content = fs.load(paths::settings_file()).await.unwrap();
|
|
let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
|
|
|
|
// Check that the agent settings contain the selected model
|
|
assert_eq!(
|
|
settings_json["agent"]["default_model"]["model"],
|
|
json!("fake")
|
|
);
|
|
assert_eq!(
|
|
settings_json["agent"]["default_model"]["provider"],
|
|
json!("fake")
|
|
);
|
|
|
|
// Register a thinking model and select it.
|
|
cx.update(|cx| {
|
|
let thinking_provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
thinking_provider.update_model("fake-thinking", |model| {
|
|
model.name = LanguageModelName::from("Fake Thinking".to_string());
|
|
model.supports_thinking = true;
|
|
});
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(thinking_provider, cx);
|
|
});
|
|
});
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
|
|
.await
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// Verify enable_thinking was written to settings as true.
|
|
let settings_content = fs.load(paths::settings_file()).await.unwrap();
|
|
let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
|
|
assert_eq!(
|
|
settings_json["agent"]["default_model"]["enable_thinking"],
|
|
json!(true),
|
|
"selecting a thinking model should persist enable_thinking: true to settings"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_select_model_updates_thinking_enabled(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.create_dir(paths::settings_file().parent().unwrap())
|
|
.await
|
|
.unwrap();
|
|
fs.insert_file(paths::settings_file(), b"{}".to_vec()).await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
let connection = NativeAgentConnection(agent.clone());
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
Rc::new(connection.clone()).new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
|
|
// Register a second provider with a thinking model.
|
|
cx.update(|cx| {
|
|
let thinking_provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
thinking_provider.update_model("fake-thinking", |model| {
|
|
model.name = LanguageModelName::from("Fake Thinking".to_string());
|
|
model.supports_thinking = true;
|
|
});
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(thinking_provider, cx);
|
|
});
|
|
});
|
|
// Refresh the agent's model list so it picks up the new provider.
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
// Thread starts with thinking_enabled = false (the default).
|
|
agent.read_with(cx, |agent, _| {
|
|
let session = agent.sessions.get(&session_id).unwrap();
|
|
session.thread.read_with(cx, |thread, _| {
|
|
assert!(!thread.thinking_enabled(), "thinking defaults to false");
|
|
});
|
|
});
|
|
|
|
// Select the thinking model via select_model.
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
// select_model should have enabled thinking based on the model's supports_thinking().
|
|
agent.read_with(cx, |agent, _| {
|
|
let session = agent.sessions.get(&session_id).unwrap();
|
|
session.thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.thinking_enabled(),
|
|
"select_model should enable thinking when model supports it"
|
|
);
|
|
});
|
|
});
|
|
|
|
// Switch back to the non-thinking model.
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake/fake"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
// select_model should have disabled thinking.
|
|
agent.read_with(cx, |agent, _| {
|
|
let session = agent.sessions.get(&session_id).unwrap();
|
|
session.thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
!thread.thinking_enabled(),
|
|
"select_model should disable thinking when model does not support it"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_summarization_model_survives_transient_registry_clearing(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent =
|
|
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.summarization_model().is_some(),
|
|
"session should have a summarization model from the test registry"
|
|
);
|
|
});
|
|
|
|
// Simulate what happens during a provider blip:
|
|
// update_active_language_model_from_settings calls set_default_model(None)
|
|
// when it can't resolve the model, clearing all fallbacks.
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.set_default_model(None, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.summarization_model().is_some(),
|
|
"summarization model should survive a transient default model clearing"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_loaded_thread_preserves_thinking_enabled(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
// Register a thinking model.
|
|
let thinking_provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
let thinking_model = thinking_provider.update_model("fake-thinking", |model| {
|
|
model.name = LanguageModelName::from("Fake Thinking".to_string());
|
|
model.supports_thinking = true;
|
|
});
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(thinking_provider.clone(), cx);
|
|
});
|
|
});
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
// Create a thread and select the thinking model.
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/fake-thinking"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify thinking is enabled after selecting the thinking model.
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.thinking_enabled(),
|
|
"thinking should be enabled after selecting thinking model"
|
|
);
|
|
});
|
|
|
|
// Send a message so the thread gets persisted.
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
thinking_provider.send_last_text(&thinking_model, "Response.");
|
|
thinking_provider.end_last(&thinking_model);
|
|
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// Close the session so it can be reloaded from disk.
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(agent.sessions.is_empty());
|
|
});
|
|
|
|
// Reload the thread and verify thinking_enabled is still true.
|
|
let reloaded_acp_thread = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let reloaded_thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
reloaded_thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.thinking_enabled(),
|
|
"thinking_enabled should be preserved when reloading a thread with a thinking model"
|
|
);
|
|
});
|
|
|
|
drop(reloaded_acp_thread);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_loaded_thread_preserves_model(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
// Register a model where id() != name(), like real Anthropic models
|
|
// (e.g. id="claude-sonnet-4-5-thinking-latest", name="Claude Sonnet 4.5 Thinking").
|
|
let provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
let model = provider.update_model("custom-model-id", |model| {
|
|
model.name = LanguageModelName::from("Custom Model Display Name".to_string());
|
|
});
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(provider.clone(), cx);
|
|
});
|
|
});
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
// Create a thread and select the model.
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
thread.read_with(cx, |thread, _| {
|
|
assert_eq!(
|
|
thread.model().unwrap().id().0.as_ref(),
|
|
"custom-model-id",
|
|
"model should be set before persisting"
|
|
);
|
|
});
|
|
|
|
// Send a message so the thread gets persisted.
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
provider.send_last_text(&model, "Response.");
|
|
provider.end_last(&model);
|
|
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// Close the session so it can be reloaded from disk.
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(agent.sessions.is_empty());
|
|
});
|
|
|
|
// Reload the thread and verify the model was preserved.
|
|
let reloaded_acp_thread = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let reloaded_thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
reloaded_thread.read_with(cx, |thread, _| {
|
|
let reloaded_model = thread
|
|
.model()
|
|
.expect("model should be present after reload");
|
|
assert_eq!(
|
|
reloaded_model.id().0.as_ref(),
|
|
"custom-model-id",
|
|
"reloaded thread should have the same model, not fall back to the default"
|
|
);
|
|
});
|
|
|
|
drop(reloaded_acp_thread);
|
|
}
|
|
|
|
async fn persist_thread_with_fake_corp_model(
|
|
cx: &mut TestAppContext,
|
|
) -> (
|
|
Entity<NativeAgent>,
|
|
Rc<NativeAgentConnection>,
|
|
Entity<Project>,
|
|
acp_v2::SessionId,
|
|
Arc<FakeLanguageModelProvider>,
|
|
) {
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
LanguageModelProviderName::from("Fake Corp".to_string()),
|
|
));
|
|
let model = provider.update_model("custom-model-id", |model| {
|
|
model.name = LanguageModelName::from("Custom Model Display Name".to_string());
|
|
});
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(provider.clone(), cx);
|
|
});
|
|
});
|
|
agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection.clone().new_session(
|
|
project.clone(),
|
|
PathList::new(&[Path::new("/a")]),
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
provider.send_last_text(&model, "Response.");
|
|
provider.end_last(&model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
|
|
(agent, connection, project, session_id, provider)
|
|
}
|
|
|
|
fn unregister_fake_corp(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.unregister_provider(
|
|
LanguageModelProviderId::from("fake-corp".to_string()),
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_loaded_thread_resolves_model_when_provider_loads_late(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let (agent, _connection, project, session_id, provider) =
|
|
persist_thread_with_fake_corp_model(cx).await;
|
|
|
|
// Simulate a restart where the provider hasn't fetched its model list
|
|
// yet, so the saved selection can't be resolved at load time.
|
|
unregister_fake_corp(cx);
|
|
|
|
let reloaded_acp_thread = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
thread.read_with(cx, |thread, _| {
|
|
assert!(
|
|
thread.model().is_none(),
|
|
"should not fall back to an unrelated model"
|
|
);
|
|
});
|
|
|
|
// The original selection is persisted even while unresolved, so a save
|
|
// during the window can't overwrite the user's choice with a fallback.
|
|
let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await;
|
|
let saved = db_thread.model.expect("selection should be persisted");
|
|
assert_eq!(saved.provider, "fake-corp");
|
|
assert_eq!(saved.model, "custom-model-id");
|
|
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(provider.clone(), cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert_eq!(
|
|
thread
|
|
.model()
|
|
.expect("model should resolve once provider loads")
|
|
.id()
|
|
.0
|
|
.as_ref(),
|
|
"custom-model-id"
|
|
);
|
|
});
|
|
|
|
drop(reloaded_acp_thread);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_explicit_model_selection_cancels_pending(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let (agent, connection, project, session_id, provider) =
|
|
persist_thread_with_fake_corp_model(cx).await;
|
|
|
|
unregister_fake_corp(cx);
|
|
|
|
let reloaded_acp_thread = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
thread.read_with(cx, |thread, _| {
|
|
assert!(thread.model().is_none());
|
|
});
|
|
|
|
// The user explicitly picks a different, available model.
|
|
let other_provider = Arc::new(FakeLanguageModelProvider::new(
|
|
LanguageModelProviderId::from("other-corp".to_string()),
|
|
LanguageModelProviderName::from("Other Corp".to_string()),
|
|
));
|
|
other_provider.update_model("other-model-id", |model| {
|
|
model.name = LanguageModelName::from("Other Model".to_string());
|
|
});
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(other_provider, cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let selector = connection.model_selector(&session_id).unwrap();
|
|
cx.update(|cx| selector.select_model(AgentModelId::new("other-corp/other-model-id"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert_eq!(thread.model().unwrap().id().0.as_ref(), "other-model-id");
|
|
});
|
|
|
|
// The original provider returning must not clobber the explicit choice.
|
|
cx.update(|cx| {
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry.register_provider(provider.clone(), cx);
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert_eq!(
|
|
thread.model().unwrap().id().0.as_ref(),
|
|
"other-model-id",
|
|
"a late provider load must not override the explicit selection"
|
|
);
|
|
});
|
|
|
|
drop(reloaded_acp_thread);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_load_thread(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"b.md": "Lorem"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
// Ensure empty threads are not saved, even if they get mutated.
|
|
let model = fake.model("fake");
|
|
let summary_model = fake.model("summary");
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread.set_summarization_model(Some(summary_model.clone()), cx);
|
|
});
|
|
cx.run_until_parked();
|
|
assert_eq!(thread_entries(&thread_store, cx), vec![]);
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| {
|
|
thread.send(
|
|
vec![
|
|
"What does ".into(),
|
|
acp_v2::ContentBlock::ResourceLink(acp_v2::ResourceLink::new(
|
|
"b.md",
|
|
MentionUri::File {
|
|
abs_path: path!("/a/b.md").into(),
|
|
}
|
|
.to_uri()
|
|
.to_string(),
|
|
)),
|
|
" mean?".into(),
|
|
],
|
|
cx,
|
|
)
|
|
});
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
let request = fake.pending_completions_for(&model).pop().unwrap();
|
|
fake.send_text(&model, &request, "Lorem.");
|
|
fake.send_event(
|
|
&model,
|
|
&request,
|
|
LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage {
|
|
input_tokens: 150,
|
|
output_tokens: 75,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
fake.end_stream(&model, &request);
|
|
cx.run_until_parked();
|
|
let summary_request = fake.pending_completions_for(&summary_model).pop().unwrap();
|
|
fake.send_text(
|
|
&summary_model,
|
|
&summary_request,
|
|
format!("Explaining {}", path!("/a/b.md")),
|
|
);
|
|
fake.end_stream(&summary_model, &summary_request);
|
|
|
|
send.await.unwrap();
|
|
let uri = MentionUri::File {
|
|
abs_path: path!("/a/b.md").into(),
|
|
}
|
|
.to_uri();
|
|
acp_thread.read_with(cx, |thread, cx| {
|
|
assert_eq!(
|
|
thread.to_markdown(cx),
|
|
formatdoc! {"
|
|
## User
|
|
|
|
What does [@b.md]({uri}) mean?
|
|
|
|
## Assistant
|
|
|
|
Lorem.
|
|
|
|
"}
|
|
)
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
// Set a draft prompt with rich content blocks and scroll position
|
|
// AFTER run_until_parked, so the only save that captures these
|
|
// changes is the one performed by close_session itself.
|
|
let draft_blocks = vec![
|
|
acp_v2::ContentBlock::Text(acp_v2::TextContent::new("Check out ")),
|
|
acp_v2::ContentBlock::ResourceLink(acp_v2::ResourceLink::new("b.md", uri.to_string())),
|
|
acp_v2::ContentBlock::Text(acp_v2::TextContent::new(" please")),
|
|
];
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
|
|
});
|
|
thread.update(cx, |thread, _cx| {
|
|
thread.set_ui_scroll_position(Some(gpui::ListOffset {
|
|
item_ix: 5,
|
|
offset_in_item: gpui::px(12.5),
|
|
}));
|
|
});
|
|
|
|
// Close the session so it can be reloaded from disk.
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert_eq!(agent.sessions.keys().cloned().collect::<Vec<_>>(), []);
|
|
});
|
|
|
|
// Ensure the thread can be reloaded from disk.
|
|
assert_eq!(
|
|
thread_entries(&thread_store, cx),
|
|
vec![(
|
|
session_id.clone(),
|
|
format!("Explaining {}", path!("/a/b.md"))
|
|
)]
|
|
);
|
|
let acp_thread = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
acp_thread.read_with(cx, |thread, cx| {
|
|
assert_eq!(
|
|
thread.to_markdown(cx),
|
|
formatdoc! {"
|
|
## User
|
|
|
|
What does [@b.md]({uri}) mean?
|
|
|
|
## Assistant
|
|
|
|
Lorem.
|
|
|
|
"}
|
|
)
|
|
});
|
|
|
|
// Ensure the draft prompt with rich content blocks survived the round-trip.
|
|
acp_thread.read_with(cx, |thread, _| {
|
|
assert_eq!(thread.draft_prompt(), Some(draft_blocks.as_slice()));
|
|
});
|
|
|
|
// Ensure token usage survived the round-trip.
|
|
acp_thread.read_with(cx, |thread, _| {
|
|
let usage = thread
|
|
.token_usage()
|
|
.expect("token usage should be restored after reload");
|
|
assert_eq!(usage.input_tokens, 150);
|
|
assert_eq!(usage.output_tokens, 75);
|
|
});
|
|
|
|
// Ensure scroll position survived the round-trip.
|
|
acp_thread.read_with(cx, |thread, _| {
|
|
let scroll = thread
|
|
.ui_scroll_position()
|
|
.expect("scroll position should be restored after reload");
|
|
assert_eq!(scroll.item_ix, 5);
|
|
assert_eq!(scroll.offset_in_item, gpui::px(12.5));
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_releasing_session_saves_thread(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"file.txt": "hello"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
let model = fake.model("fake");
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
});
|
|
|
|
// Send a message so the thread is non-empty (empty threads aren't saved).
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
fake.send_last_text(&model, "world");
|
|
fake.end_last(&model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
// Set a draft prompt WITHOUT calling run_until_parked afterwards.
|
|
// This means no observe-triggered save has run for this change.
|
|
// The only way this data gets persisted is if close_session
|
|
// itself performs the save.
|
|
let draft_blocks = vec![acp_v2::ContentBlock::Text(acp_v2::TextContent::new(
|
|
"unsaved draft",
|
|
))];
|
|
acp_thread.update(cx, |thread, cx| {
|
|
thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
|
|
});
|
|
|
|
// Close the session immediately — no run_until_parked in between.
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(agent.sessions.is_empty());
|
|
});
|
|
|
|
// Reopen and verify the draft prompt was saved.
|
|
let reloaded = agent
|
|
.update(cx, |agent, cx| {
|
|
agent.open_thread(session_id.clone(), project.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
reloaded.read_with(cx, |thread, _| {
|
|
assert_eq!(
|
|
thread.draft_prompt(),
|
|
Some(draft_blocks.as_slice()),
|
|
"releasing the session must save the thread; draft prompt was lost"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_releasing_thread_releases_subagent_sessions_and_project(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"file.txt": "hello"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
let warmup_project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
drop(warmup_project);
|
|
release_dropped_entities(cx);
|
|
|
|
let leak_snapshot = cx.update(|cx| cx.leak_detector_snapshot());
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let weak_project = project.downgrade();
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let environment = NativeThreadEnvironment {
|
|
agent: agent.downgrade(),
|
|
thread: thread.downgrade(),
|
|
acp_thread: acp_thread.downgrade(),
|
|
};
|
|
|
|
let first_subagent = cx
|
|
.update(|cx| environment.create_subagent_thread("first".to_string(), None, cx))
|
|
.unwrap();
|
|
let second_subagent = cx
|
|
.update(|cx| environment.create_subagent_thread("second".to_string(), None, cx))
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let session_ids = agent.read_with(cx, |agent, _| {
|
|
let mut ids = agent
|
|
.sessions
|
|
.keys()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
ids.sort();
|
|
ids
|
|
});
|
|
let mut expected_ids = vec![
|
|
session_id.to_string(),
|
|
first_subagent.id().to_string(),
|
|
second_subagent.id().to_string(),
|
|
];
|
|
expected_ids.sort();
|
|
assert_eq!(session_ids, expected_ids);
|
|
|
|
drop(first_subagent);
|
|
drop(second_subagent);
|
|
drop(environment);
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
drop(project);
|
|
release_dropped_entities(cx);
|
|
|
|
cx.update(|cx| cx.assert_no_new_leaks(&leak_snapshot));
|
|
let (session_count, project_count) =
|
|
agent.read_with(cx, |agent, _| (agent.sessions.len(), agent.projects.len()));
|
|
assert_eq!(session_count, 0);
|
|
assert_eq!(project_count, 0);
|
|
assert!(weak_project.upgrade().is_none());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_burst_preserves_in_flight_write(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"file.txt": "hello"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
let model = fake.model("fake");
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
});
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
fake.send_last_text(&model, "world");
|
|
fake.end_last(&model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap();
|
|
|
|
let [first_draft, second_draft, third_draft] = ["draft one", "draft two", "draft three"]
|
|
.map(|text| vec![acp_v2::ContentBlock::Text(acp_v2::TextContent::new(text))]);
|
|
|
|
let (first_gate_tx, first_gate_rx) = oneshot::channel();
|
|
database.set_write_gate(first_gate_rx);
|
|
set_draft_and_save(&agent, &acp_thread, &thread, first_draft.clone(), cx);
|
|
cx.run_until_parked();
|
|
|
|
set_draft_and_save(&agent, &acp_thread, &thread, second_draft, cx);
|
|
set_draft_and_save(&agent, &acp_thread, &thread, third_draft.clone(), cx);
|
|
cx.run_until_parked();
|
|
|
|
let (second_gate_tx, second_gate_rx) = oneshot::channel();
|
|
database.set_write_gate(second_gate_rx);
|
|
first_gate_tx.send(()).ok();
|
|
cx.run_until_parked();
|
|
|
|
let db_thread = database
|
|
.load_thread(session_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
db_thread.draft_prompt,
|
|
Some(first_draft),
|
|
"save requests arriving during an in-flight write must not cancel it"
|
|
);
|
|
|
|
second_gate_tx.send(()).ok();
|
|
cx.run_until_parked();
|
|
|
|
let db_thread = database
|
|
.load_thread(session_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
db_thread.draft_prompt,
|
|
Some(third_draft),
|
|
"the save worker must persist the latest snapshot of a burst"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_streaming_chunks_do_not_save_thread(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
let saves_before_stream = turn.database.save_count();
|
|
|
|
for chunk in ["one ", "two ", "three"] {
|
|
turn.fake.send_last_text(&turn.model, chunk);
|
|
cx.run_until_parked();
|
|
}
|
|
assert_eq!(
|
|
turn.database.save_count(),
|
|
saves_before_stream,
|
|
"streamed chunks must not save the thread"
|
|
);
|
|
|
|
turn.fake.end_last(&turn.model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
assert!(turn.database.save_count() > saves_before_stream);
|
|
assert_eq!(
|
|
turn.saved_markdown().await,
|
|
"## User\n\nhello\n\n## Assistant\n\none two three\n"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_completed_message_saved_while_next_message_streams(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
|
|
turn.fake.send_last_event(
|
|
&turn.model,
|
|
LanguageModelCompletionEvent::StartMessage {
|
|
message_id: "first".into(),
|
|
},
|
|
);
|
|
turn.fake.send_last_text(&turn.model, "first message");
|
|
turn.fake.send_last_event(
|
|
&turn.model,
|
|
LanguageModelCompletionEvent::StartMessage {
|
|
message_id: "second".into(),
|
|
},
|
|
);
|
|
turn.fake.send_last_text(&turn.model, "second mess");
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_markdown().await,
|
|
"## User\n\nhello\n\n## Assistant\n\nfirst message\n",
|
|
"a completed message must be saved even while the next one streams"
|
|
);
|
|
|
|
turn.fake.send_last_text(&turn.model, "age");
|
|
turn.fake.end_last(&turn.model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_markdown().await,
|
|
"## User\n\nhello\n\n## Assistant\n\nfirst message\n\n## Assistant\n\nsecond message\n"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_cancelling_stream_saves_partial_message(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
|
|
turn.fake.send_last_text(&turn.model, "partial");
|
|
cx.run_until_parked();
|
|
turn.acp_thread
|
|
.update(cx, |thread, cx| thread.cancel(cx))
|
|
.await;
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_markdown().await,
|
|
"## User\n\nhello\n\n## Assistant\n\npartial\n"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_settings_changes_save_while_message_streams(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
turn.fake.send_last_text(&turn.model, "one ");
|
|
cx.run_until_parked();
|
|
|
|
turn.thread.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(Some("high".into()), cx);
|
|
});
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_thread().await.thinking_effort.as_deref(),
|
|
Some("high")
|
|
);
|
|
|
|
turn.thread
|
|
.update(cx, |thread, cx| thread.set_title("Renamed".into(), cx));
|
|
cx.run_until_parked();
|
|
assert_eq!(turn.saved_thread().await.title, "Renamed");
|
|
|
|
let draft = vec![acp_v2::ContentBlock::Text(acp_v2::TextContent::new(
|
|
"next prompt",
|
|
))];
|
|
turn.acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.set_draft_prompt(Some(draft.clone()), cx);
|
|
});
|
|
// The thread view notifies the thread after a draft edit to request a save.
|
|
turn.thread.update(cx, |_, cx| cx.notify());
|
|
cx.run_until_parked();
|
|
assert_eq!(turn.saved_thread().await.draft_prompt, Some(draft));
|
|
|
|
let saves_after_changes = turn.database.save_count();
|
|
turn.fake.send_last_text(&turn.model, "two");
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.database.save_count(),
|
|
saves_after_changes,
|
|
"streamed chunks after a saved change must not save the thread"
|
|
);
|
|
|
|
turn.fake.end_last(&turn.model);
|
|
send.await.unwrap();
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_token_usage_does_not_save_while_message_streams(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
turn.fake.send_last_text(&turn.model, "one ");
|
|
cx.run_until_parked();
|
|
let saves_before_usage = turn.database.save_count();
|
|
|
|
// Some providers, like Google, report usage with every streamed chunk.
|
|
for output_tokens in [10, 20, 30] {
|
|
turn.fake.send_last_event(
|
|
&turn.model,
|
|
LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage {
|
|
input_tokens: 100,
|
|
output_tokens,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
cx.run_until_parked();
|
|
}
|
|
assert_eq!(turn.database.save_count(), saves_before_usage);
|
|
|
|
turn.fake.end_last(&turn.model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
let expected_usage = language_model::TokenUsage {
|
|
input_tokens: 100,
|
|
output_tokens: 30,
|
|
..Default::default()
|
|
};
|
|
let saved_thread = turn.saved_thread().await;
|
|
assert_eq!(saved_thread.cumulative_token_usage, expected_usage);
|
|
assert_eq!(
|
|
saved_thread
|
|
.request_token_usage
|
|
.into_values()
|
|
.collect::<Vec<_>>(),
|
|
vec![expected_usage]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_token_usage_saved_when_message_ends_without_content(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
// A titled thread doesn't generate a title when the turn ends, which
|
|
// would otherwise save the thread.
|
|
turn.thread
|
|
.update(cx, |thread, cx| thread.set_title("Title".into(), cx));
|
|
turn.send_start_message_with_usage(100);
|
|
cx.run_until_parked();
|
|
|
|
turn.fake.end_last(&turn.model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_thread()
|
|
.await
|
|
.cumulative_token_usage
|
|
.input_tokens,
|
|
100
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_token_usage_saved_when_cancelled_before_content(cx: &mut TestAppContext) {
|
|
let (turn, send) = StreamingTurn::start(cx).await;
|
|
// A titled thread doesn't generate a title when the turn ends, which
|
|
// would otherwise save the thread.
|
|
turn.thread
|
|
.update(cx, |thread, cx| thread.set_title("Title".into(), cx));
|
|
turn.send_start_message_with_usage(100);
|
|
cx.run_until_parked();
|
|
|
|
turn.acp_thread
|
|
.update(cx, |thread, cx| thread.cancel(cx))
|
|
.await;
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
turn.saved_thread()
|
|
.await
|
|
.cumulative_token_usage
|
|
.input_tokens,
|
|
100
|
|
);
|
|
}
|
|
|
|
struct StreamingTurn {
|
|
fake: Arc<FakeLanguageModelProvider>,
|
|
model: LanguageModel,
|
|
acp_thread: Entity<AcpThread>,
|
|
thread: Entity<Thread>,
|
|
session_id: acp_v2::SessionId,
|
|
database: Arc<ThreadsDatabase>,
|
|
}
|
|
|
|
impl StreamingTurn {
|
|
async fn start(
|
|
cx: &mut TestAppContext,
|
|
) -> (Self, Task<Result<Option<acp_thread::SubmissionResponse>>>) {
|
|
let fake = init_test(cx);
|
|
let (_connection, agent, _project, acp_thread) = setup_native_agent_session(cx).await;
|
|
let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
|
|
let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx));
|
|
let model = fake.model("fake");
|
|
thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx));
|
|
let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap();
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
let turn = Self {
|
|
fake,
|
|
model,
|
|
acp_thread,
|
|
thread,
|
|
session_id,
|
|
database,
|
|
};
|
|
(turn, send)
|
|
}
|
|
|
|
async fn saved_thread(&self) -> DbThread {
|
|
self.database
|
|
.load_thread(self.session_id.clone())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
}
|
|
|
|
async fn saved_markdown(&self) -> String {
|
|
crate::thread::messages_to_markdown(&self.saved_thread().await.messages)
|
|
}
|
|
|
|
/// Providers like Anthropic report usage before streaming any content.
|
|
fn send_start_message_with_usage(&self, input_tokens: u64) {
|
|
self.fake.send_last_event(
|
|
&self.model,
|
|
LanguageModelCompletionEvent::StartMessage {
|
|
message_id: "message".into(),
|
|
},
|
|
);
|
|
self.fake.send_last_event(
|
|
&self.model,
|
|
LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage {
|
|
input_tokens,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_thread_summary_releases_loaded_session(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"file.txt": "hello"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
let model = fake.model("fake");
|
|
let summary_model = fake.model("summary");
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread.set_summarization_model(Some(summary_model.clone()), cx);
|
|
});
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
let request = fake.pending_completions_for(&model).pop().unwrap();
|
|
fake.send_text(&model, &request, "world");
|
|
fake.end_stream(&model, &request);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let summary = agent.update(cx, |agent, cx| {
|
|
agent.thread_summary(session_id.clone(), project.clone(), cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let summary_request = fake.pending_completions_for(&summary_model).pop().unwrap();
|
|
fake.send_text(&summary_model, &summary_request, "summary");
|
|
fake.end_stream(&summary_model, &summary_request);
|
|
|
|
assert_eq!(summary.await.unwrap(), "summary");
|
|
cx.run_until_parked();
|
|
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(
|
|
agent.sessions.contains_key(&session_id),
|
|
"thread_summary should not close the active session"
|
|
);
|
|
});
|
|
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(
|
|
agent.sessions.is_empty(),
|
|
"dropping the active session after thread_summary should unload it"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut TestAppContext) {
|
|
let fake = init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/",
|
|
json!({
|
|
"a": {
|
|
"file.txt": "hello"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
let model = fake.model("fake");
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
});
|
|
|
|
let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
|
|
let send = cx.foreground_executor().spawn(send);
|
|
cx.run_until_parked();
|
|
|
|
fake.send_last_text(&model, "world");
|
|
fake.end_last(&model);
|
|
send.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
drop(thread);
|
|
drop(acp_thread);
|
|
release_dropped_entities(cx);
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(agent.sessions.is_empty());
|
|
});
|
|
|
|
let first_loaded_thread = cx.update(|cx| {
|
|
connection.clone().load_session(
|
|
session_id.clone(),
|
|
project.clone(),
|
|
PathList::new(&[Path::new("")]),
|
|
None,
|
|
cx,
|
|
)
|
|
});
|
|
let second_loaded_thread = cx.update(|cx| {
|
|
connection.clone().load_session(
|
|
session_id.clone(),
|
|
project.clone(),
|
|
PathList::new(&[Path::new("")]),
|
|
None,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let first_loaded_thread = first_loaded_thread.await.unwrap();
|
|
let second_loaded_thread = second_loaded_thread.await.unwrap();
|
|
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
first_loaded_thread.entity_id(),
|
|
second_loaded_thread.entity_id(),
|
|
"concurrent loads for the same session should share one AcpThread"
|
|
);
|
|
|
|
drop(first_loaded_thread);
|
|
release_dropped_entities(cx);
|
|
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(
|
|
agent.sessions.contains_key(&session_id),
|
|
"dropping one loaded handle should not drop shared session state"
|
|
);
|
|
});
|
|
|
|
let follow_up = second_loaded_thread.update(cx, |thread, cx| {
|
|
thread.send(vec!["still there?".into()], cx)
|
|
});
|
|
let follow_up = cx.foreground_executor().spawn(follow_up);
|
|
cx.run_until_parked();
|
|
|
|
fake.send_last_text(&model, "yes");
|
|
fake.end_last(&model);
|
|
follow_up.await.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
second_loaded_thread.read_with(cx, |thread, cx| {
|
|
assert_eq!(
|
|
thread.to_markdown(cx),
|
|
formatdoc! {"
|
|
## User
|
|
|
|
hello
|
|
|
|
## Assistant
|
|
|
|
world
|
|
|
|
## User
|
|
|
|
still there?
|
|
|
|
## Assistant
|
|
|
|
yes
|
|
|
|
"}
|
|
);
|
|
});
|
|
|
|
drop(second_loaded_thread);
|
|
release_dropped_entities(cx);
|
|
|
|
agent.read_with(cx, |agent, _| {
|
|
assert!(agent.sessions.is_empty());
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_rapid_title_changes_do_not_loop(cx: &mut TestAppContext) {
|
|
// Regression test: rapid title changes must not cause a propagation loop
|
|
// between Thread and AcpThread via handle_thread_title_updated.
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/", json!({ "a": {} })).await;
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
let thread_store = cx.new(|cx| ThreadStore::new(cx));
|
|
let agent = cx
|
|
.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
|
|
let connection = Rc::new(NativeAgentConnection(agent.clone()));
|
|
|
|
let acp_thread = cx
|
|
.update(|cx| {
|
|
connection
|
|
.clone()
|
|
.new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
|
|
let thread = agent.read_with(cx, |agent, _| {
|
|
agent.sessions.get(&session_id).unwrap().thread.clone()
|
|
});
|
|
|
|
let title_updated_count = Rc::new(std::cell::RefCell::new(0usize));
|
|
cx.update(|cx| {
|
|
let count = title_updated_count.clone();
|
|
cx.subscribe(
|
|
&thread,
|
|
move |_entity: Entity<Thread>, _event: &TitleUpdated, _cx: &mut App| {
|
|
let new_count = {
|
|
let mut count = count.borrow_mut();
|
|
*count += 1;
|
|
*count
|
|
};
|
|
assert!(
|
|
new_count <= 2,
|
|
"TitleUpdated fired {new_count} times; \
|
|
title updates are looping"
|
|
);
|
|
},
|
|
)
|
|
.detach();
|
|
});
|
|
|
|
thread.update(cx, |thread, cx| thread.set_title("first".into(), cx));
|
|
thread.update(cx, |thread, cx| thread.set_title("second".into(), cx));
|
|
|
|
cx.run_until_parked();
|
|
|
|
thread.read_with(cx, |thread, _| {
|
|
assert_eq!(thread.title(), Some("second".into()));
|
|
});
|
|
acp_thread.read_with(cx, |acp_thread, _| {
|
|
assert_eq!(acp_thread.title(), Some("second".into()));
|
|
});
|
|
|
|
assert_eq!(*title_updated_count.borrow(), 2);
|
|
}
|
|
|
|
fn thread_entries(
|
|
thread_store: &Entity<ThreadStore>,
|
|
cx: &mut TestAppContext,
|
|
) -> Vec<(acp_v2::SessionId, String)> {
|
|
thread_store.read_with(cx, |store, _| {
|
|
store
|
|
.entries()
|
|
.map(|entry| (entry.id.clone(), entry.title.to_string()))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
}
|
|
|
|
/// Returns the fake provider that serves the registry's default model.
|
|
fn init_test(cx: &mut TestAppContext) -> Arc<FakeLanguageModelProvider> {
|
|
env_logger::try_init().ok();
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
|
|
LanguageModelRegistry::test(cx)
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_slash_command_prefix_keeps_inline_args() {
|
|
// The bug being guarded against: skill slash invocation used to
|
|
// discard the entire first text block, which threw away anything
|
|
// the user typed on the same line as the command.
|
|
assert_eq!(
|
|
strip_slash_command_prefix("/fix-review #1, #2, #3"),
|
|
"#1, #2, #3",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_slash_command_prefix_preserves_newlines() {
|
|
// Continuations across newlines are common when users compose
|
|
// structured prompts; the first newline is the command terminator,
|
|
// but everything after it must reach the model verbatim.
|
|
assert_eq!(
|
|
strip_slash_command_prefix("/fix-review\nline 1\nline 2"),
|
|
"line 1\nline 2",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_slash_command_prefix_command_only_is_empty() {
|
|
assert_eq!(strip_slash_command_prefix("/fix-review"), "");
|
|
assert_eq!(strip_slash_command_prefix("/fix-review "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_slash_command_prefix_ignores_leading_whitespace() {
|
|
assert_eq!(strip_slash_command_prefix(" /fix-review hello"), "hello",);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_slash_command_prefix_passes_through_non_command_text() {
|
|
// Defense in depth: if somehow we're called with a non-slash-prefixed
|
|
// block, the safe behavior is to return it unchanged rather than
|
|
// silently mangling unrelated user text.
|
|
assert_eq!(strip_slash_command_prefix("hello world"), "hello world",);
|
|
}
|
|
|
|
fn set_draft_and_save(
|
|
agent: &Entity<NativeAgent>,
|
|
acp_thread: &Entity<AcpThread>,
|
|
thread: &Entity<Thread>,
|
|
draft: Vec<acp_v2::ContentBlock>,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
acp_thread.update(cx, |acp_thread, cx| {
|
|
acp_thread.set_draft_prompt(Some(draft), cx);
|
|
});
|
|
agent.update(cx, |agent, cx| {
|
|
agent.save_thread(thread.clone(), cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn mcp_message_content_to_acp_content_block(
|
|
content: context_server::types::MessageContent,
|
|
) -> acp_v1::ContentBlock {
|
|
match content {
|
|
context_server::types::MessageContent::Text {
|
|
text,
|
|
annotations: _,
|
|
} => text.into(),
|
|
context_server::types::MessageContent::Image {
|
|
data,
|
|
mime_type,
|
|
annotations: _,
|
|
} => acp_v1::ContentBlock::Image(acp_v1::ImageContent::new(data, mime_type)),
|
|
context_server::types::MessageContent::Audio {
|
|
data,
|
|
mime_type,
|
|
annotations: _,
|
|
} => acp_v1::ContentBlock::Audio(acp_v1::AudioContent::new(data, mime_type)),
|
|
context_server::types::MessageContent::Resource {
|
|
resource,
|
|
annotations: _,
|
|
} => {
|
|
let mut link =
|
|
acp_v1::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
|
|
if let Some(mime_type) = resource.mime_type {
|
|
link = link.mime_type(mime_type);
|
|
}
|
|
acp_v1::ContentBlock::ResourceLink(link)
|
|
}
|
|
}
|
|
}
|