Detailed changes
@@ -300,6 +300,12 @@
"ctrl-enter": "menu::Confirm",
},
},
+ {
+ "context": "AcpThread",
+ "bindings": {
+ "ctrl--": "pane::GoBack",
+ },
+ },
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
@@ -346,6 +346,12 @@
"cmd-enter": "menu::Confirm",
},
},
+ {
+ "context": "AcpThread",
+ "bindings": {
+ "ctrl--": "pane::GoBack",
+ },
+ },
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
@@ -302,6 +302,12 @@
"ctrl-enter": "menu::Confirm",
},
},
+ {
+ "context": "AcpThread",
+ "bindings": {
+ "ctrl--": "pane::GoBack",
+ },
+ },
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
@@ -9,8 +9,8 @@ use agent_settings::AgentSettings;
/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field.
pub const TOOL_NAME_META_KEY: &str = "tool_name";
-/// The tool name for subagent spawning
-pub const SUBAGENT_TOOL_NAME: &str = "subagent";
+/// Key used in ACP ToolCall meta to store the session id when a subagent is spawned.
+pub const SUBAGENT_SESSION_ID_META_KEY: &str = "subagent_session_id";
/// Helper to extract tool name from ACP meta
pub fn tool_name_from_meta(meta: &Option<acp::Meta>) -> Option<SharedString> {
@@ -20,6 +20,14 @@ pub fn tool_name_from_meta(meta: &Option<acp::Meta>) -> Option<SharedString> {
.map(|s| SharedString::from(s.to_owned()))
}
+/// Helper to extract subagent session id from ACP meta
+pub fn subagent_session_id_from_meta(meta: &Option<acp::Meta>) -> Option<acp::SessionId> {
+ meta.as_ref()
+ .and_then(|m| m.get(SUBAGENT_SESSION_ID_META_KEY))
+ .and_then(|v| v.as_str())
+ .map(|s| acp::SessionId::from(s.to_string()))
+}
+
/// Helper to create meta with tool name
pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta {
acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())])
@@ -216,6 +224,7 @@ pub struct ToolCall {
pub raw_input_markdown: Option<Entity<Markdown>>,
pub raw_output: Option<serde_json::Value>,
pub tool_name: Option<SharedString>,
+ pub subagent_session_id: Option<acp::SessionId>,
}
impl ToolCall {
@@ -254,6 +263,8 @@ impl ToolCall {
let tool_name = tool_name_from_meta(&tool_call.meta);
+ let subagent_session = subagent_session_id_from_meta(&tool_call.meta);
+
let result = Self {
id: tool_call.tool_call_id,
label: cx
@@ -267,6 +278,7 @@ impl ToolCall {
raw_input_markdown,
raw_output: tool_call.raw_output,
tool_name,
+ subagent_session_id: subagent_session,
};
Ok(result)
}
@@ -274,6 +286,7 @@ impl ToolCall {
fn update_fields(
&mut self,
fields: acp::ToolCallUpdateFields,
+ meta: Option<acp::Meta>,
language_registry: Arc<LanguageRegistry>,
path_style: PathStyle,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
@@ -298,6 +311,10 @@ impl ToolCall {
self.status = status.into();
}
+ if let Some(subagent_session_id) = subagent_session_id_from_meta(&meta) {
+ self.subagent_session_id = Some(subagent_session_id);
+ }
+
if let Some(title) = title {
self.label.update(cx, |label, cx| {
if self.kind == acp::ToolKind::Execute {
@@ -366,7 +383,6 @@ impl ToolCall {
ToolCallContent::Diff(diff) => Some(diff),
ToolCallContent::ContentBlock(_) => None,
ToolCallContent::Terminal(_) => None,
- ToolCallContent::SubagentThread(_) => None,
})
}
@@ -375,24 +391,12 @@ impl ToolCall {
ToolCallContent::Terminal(terminal) => Some(terminal),
ToolCallContent::ContentBlock(_) => None,
ToolCallContent::Diff(_) => None,
- ToolCallContent::SubagentThread(_) => None,
- })
- }
-
- pub fn subagent_thread(&self) -> Option<&Entity<AcpThread>> {
- self.content.iter().find_map(|content| match content {
- ToolCallContent::SubagentThread(thread) => Some(thread),
- _ => None,
})
}
pub fn is_subagent(&self) -> bool {
- matches!(self.kind, acp::ToolKind::Other)
- && self
- .tool_name
- .as_ref()
- .map(|n| n.as_ref() == SUBAGENT_TOOL_NAME)
- .unwrap_or(false)
+ self.tool_name.as_ref().is_some_and(|s| s == "subagent")
+ || self.subagent_session_id.is_some()
}
pub fn to_markdown(&self, cx: &App) -> String {
@@ -688,7 +692,6 @@ pub enum ToolCallContent {
ContentBlock(ContentBlock),
Diff(Entity<Diff>),
Terminal(Entity<Terminal>),
- SubagentThread(Entity<AcpThread>),
}
impl ToolCallContent {
@@ -760,7 +763,6 @@ impl ToolCallContent {
Self::ContentBlock(content) => content.to_markdown(cx).to_string(),
Self::Diff(diff) => diff.read(cx).to_markdown(cx),
Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx),
- Self::SubagentThread(thread) => thread.read(cx).to_markdown(cx),
}
}
@@ -770,13 +772,6 @@ impl ToolCallContent {
_ => None,
}
}
-
- pub fn subagent_thread(&self) -> Option<&Entity<AcpThread>> {
- match self {
- Self::SubagentThread(thread) => Some(thread),
- _ => None,
- }
- }
}
#[derive(Debug, PartialEq)]
@@ -784,7 +779,6 @@ pub enum ToolCallUpdate {
UpdateFields(acp::ToolCallUpdate),
UpdateDiff(ToolCallUpdateDiff),
UpdateTerminal(ToolCallUpdateTerminal),
- UpdateSubagentThread(ToolCallUpdateSubagentThread),
}
impl ToolCallUpdate {
@@ -793,7 +787,6 @@ impl ToolCallUpdate {
Self::UpdateFields(update) => &update.tool_call_id,
Self::UpdateDiff(diff) => &diff.id,
Self::UpdateTerminal(terminal) => &terminal.id,
- Self::UpdateSubagentThread(subagent) => &subagent.id,
}
}
}
@@ -828,18 +821,6 @@ pub struct ToolCallUpdateTerminal {
pub terminal: Entity<Terminal>,
}
-impl From<ToolCallUpdateSubagentThread> for ToolCallUpdate {
- fn from(subagent: ToolCallUpdateSubagentThread) -> Self {
- Self::UpdateSubagentThread(subagent)
- }
-}
-
-#[derive(Debug, PartialEq)]
-pub struct ToolCallUpdateSubagentThread {
- pub id: acp::ToolCallId,
- pub thread: Entity<AcpThread>,
-}
-
#[derive(Debug, Default)]
pub struct Plan {
pub entries: Vec<PlanEntry>,
@@ -949,6 +930,7 @@ pub struct RetryStatus {
}
pub struct AcpThread {
+ parent_session_id: Option<acp::SessionId>,
title: SharedString,
entries: Vec<AgentThreadEntry>,
plan: Plan,
@@ -987,6 +969,7 @@ pub enum AcpThreadEvent {
EntriesRemoved(Range<usize>),
ToolAuthorizationRequired,
Retry(RetryStatus),
+ SubagentSpawned(acp::SessionId),
Stopped,
Error,
LoadError(LoadError),
@@ -1163,6 +1146,7 @@ impl Error for LoadError {}
impl AcpThread {
pub fn new(
+ parent_session_id: Option<acp::SessionId>,
title: impl Into<SharedString>,
connection: Rc<dyn AgentConnection>,
project: Entity<Project>,
@@ -1185,6 +1169,7 @@ impl AcpThread {
let (user_stop_tx, _user_stop_rx) = watch::channel(false);
Self {
+ parent_session_id,
action_log,
shared_buffers: Default::default(),
entries: Default::default(),
@@ -1205,6 +1190,10 @@ impl AcpThread {
}
}
+ pub fn parent_session_id(&self) -> Option<&acp::SessionId> {
+ self.parent_session_id.as_ref()
+ }
+
pub fn prompt_capabilities(&self) -> acp::PromptCapabilities {
self.prompt_capabilities.clone()
}
@@ -1214,6 +1203,7 @@ impl AcpThread {
self.user_stopped
.store(true, std::sync::atomic::Ordering::SeqCst);
self.user_stop_tx.send(true).ok();
+ self.send_task.take();
}
pub fn was_stopped_by_user(&self) -> bool {
@@ -1479,6 +1469,10 @@ impl AcpThread {
Task::ready(Ok(()))
}
+ pub fn subagent_spawned(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
+ cx.emit(AcpThreadEvent::SubagentSpawned(session_id));
+ }
+
pub fn update_token_usage(&mut self, usage: Option<TokenUsage>, cx: &mut Context<Self>) {
self.token_usage = usage;
cx.emit(AcpThreadEvent::TokenUsageUpdated);
@@ -1518,6 +1512,7 @@ impl AcpThread {
raw_input_markdown: None,
raw_output: None,
tool_name: None,
+ subagent_session_id: None,
};
self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);
return Ok(());
@@ -1530,7 +1525,14 @@ impl AcpThread {
match update {
ToolCallUpdate::UpdateFields(update) => {
let location_updated = update.fields.locations.is_some();
- call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?;
+ call.update_fields(
+ update.fields,
+ update.meta,
+ languages,
+ path_style,
+ &self.terminals,
+ cx,
+ )?;
if location_updated {
self.resolve_locations(update.tool_call_id, cx);
}
@@ -1544,16 +1546,6 @@ impl AcpThread {
call.content
.push(ToolCallContent::Terminal(update.terminal));
}
- ToolCallUpdate::UpdateSubagentThread(update) => {
- debug_assert!(
- !call.content.iter().any(|c| {
- matches!(c, ToolCallContent::SubagentThread(existing) if existing == &update.thread)
- }),
- "Duplicate SubagentThread update for the same AcpThread entity"
- );
- call.content
- .push(ToolCallContent::SubagentThread(update.thread));
- }
}
cx.emit(AcpThreadEvent::EntryUpdated(ix));
@@ -1605,6 +1597,7 @@ impl AcpThread {
call.update_fields(
update.fields,
+ update.meta,
language_registry,
path_style,
&self.terminals,
@@ -2631,7 +2624,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, std::path::Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -2695,7 +2688,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, std::path::Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -2783,7 +2776,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project.clone(), Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project.clone(), Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -2894,7 +2887,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -2988,7 +2981,7 @@ mod tests {
));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3069,7 +3062,7 @@ mod tests {
.unwrap();
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
.await
.unwrap();
@@ -3110,7 +3103,7 @@ mod tests {
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
.await
.unwrap();
@@ -3185,7 +3178,7 @@ mod tests {
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
.await
.unwrap();
@@ -3259,7 +3252,7 @@ mod tests {
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx))
.await
.unwrap();
@@ -3307,7 +3300,7 @@ mod tests {
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3398,7 +3391,7 @@ mod tests {
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3457,7 +3450,7 @@ mod tests {
}
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3630,7 +3623,7 @@ mod tests {
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3706,7 +3699,7 @@ mod tests {
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3779,7 +3772,7 @@ mod tests {
}
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -3906,7 +3899,7 @@ mod tests {
&self.auth_methods
}
- fn new_thread(
+ fn new_session(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
@@ -3922,6 +3915,7 @@ mod tests {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|cx| {
AcpThread::new(
+ None,
"Test",
self.clone(),
project,
@@ -4011,7 +4005,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -4077,7 +4071,7 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -4390,7 +4384,7 @@ mod tests {
));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -30,7 +30,7 @@ impl UserMessageId {
pub trait AgentConnection {
fn telemetry_id(&self) -> SharedString;
- fn new_thread(
+ fn new_session(
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
@@ -53,6 +53,16 @@ pub trait AgentConnection {
Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported")))
}
+ /// Whether this agent supports closing existing sessions.
+ fn supports_close_session(&self, _cx: &App) -> bool {
+ false
+ }
+
+ /// Close an existing session. Allows the agent to free the session from memory.
+ fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported")))
+ }
+
/// Whether this agent supports resuming existing sessions without loading history.
fn supports_resume_session(&self, _cx: &App) -> bool {
false
@@ -598,7 +608,7 @@ mod test_support {
Some(self.model_selector_impl())
}
- fn new_thread(
+ fn new_session(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
@@ -608,6 +618,7 @@ mod test_support {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|cx| {
AcpThread::new(
+ None,
"Test",
self.clone(),
project,
@@ -33,7 +33,7 @@ use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
-use futures::{StreamExt, future};
+use futures::{FutureExt as _, StreamExt as _, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
@@ -49,6 +49,7 @@ use std::any::Any;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
+use std::time::Duration;
use util::ResultExt;
use util::rel_path::RelPath;
@@ -67,7 +68,7 @@ struct Session {
/// The internal thread that processes messages
thread: Entity<Thread>,
/// The ACP thread that handles protocol communication
- acp_thread: WeakEntity<acp_thread::AcpThread>,
+ acp_thread: Entity<acp_thread::AcpThread>,
pending_save: Task<()>,
_subscriptions: Vec<Subscription>,
}
@@ -333,24 +334,27 @@ impl NativeAgent {
)
});
- self.register_session(thread, cx)
+ self.register_session(thread, None, cx)
}
fn register_session(
&mut self,
thread_handle: Entity<Thread>,
+ allowed_tool_names: Option<Vec<&str>>,
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 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| {
acp_thread::AcpThread::new(
+ parent_session_id,
title,
connection,
project.clone(),
@@ -364,20 +368,20 @@ impl NativeAgent {
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
+ let weak = cx.weak_entity();
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(
- Rc::new(AcpThreadEnvironment {
+ allowed_tool_names,
+ Rc::new(NativeThreadEnvironment {
acp_thread: acp_thread.downgrade(),
+ agent: weak,
}) as _,
cx,
)
});
let subscriptions = vec![
- cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
- this.sessions.remove(acp_thread.session_id());
- }),
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| {
@@ -389,7 +393,7 @@ impl NativeAgent {
session_id,
Session {
thread: thread_handle,
- acp_thread: acp_thread.downgrade(),
+ acp_thread: acp_thread.clone(),
_subscriptions: subscriptions,
pending_save: Task::ready(()),
},
@@ -580,7 +584,7 @@ impl NativeAgent {
return;
};
let thread = thread.downgrade();
- let acp_thread = session.acp_thread.clone();
+ let acp_thread = session.acp_thread.downgrade();
cx.spawn(async move |_, cx| {
let title = thread.read_with(cx, |thread, _| thread.title())?;
let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
@@ -598,12 +602,9 @@ impl NativeAgent {
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();
+ session.acp_thread.update(cx, |acp_thread, cx| {
+ acp_thread.update_token_usage(usage.0.clone(), cx);
+ });
}
fn handle_project_event(
@@ -689,18 +690,16 @@ impl NativeAgent {
fn update_available_commands(&self, cx: &mut Context<Self>) {
let available_commands = self.build_available_commands(cx);
for session in self.sessions.values() {
- if let Some(acp_thread) = session.acp_thread.upgrade() {
- acp_thread.update(cx, |thread, cx| {
- thread
- .handle_session_update(
- acp::SessionUpdate::AvailableCommandsUpdate(
- acp::AvailableCommandsUpdate::new(available_commands.clone()),
- ),
- cx,
- )
- .log_err();
- });
- }
+ session.acp_thread.update(cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AvailableCommandsUpdate(
+ acp::AvailableCommandsUpdate::new(available_commands.clone()),
+ ),
+ cx,
+ )
+ .log_err();
+ });
}
}
@@ -796,11 +795,16 @@ impl NativeAgent {
id: acp::SessionId,
cx: &mut Context<Self>,
) -> Task<Result<Entity<AcpThread>>> {
+ if let Some(session) = self.sessions.get(&id) {
+ return Task::ready(Ok(session.acp_thread.clone()));
+ }
+
let task = self.load_thread(id, cx);
cx.spawn(async move |this, cx| {
let thread = task.await?;
- let acp_thread =
- this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?;
+ let acp_thread = this.update(cx, |this, cx| {
+ this.register_session(thread.clone(), None, cx)
+ })?;
let events = thread.update(cx, |thread, cx| thread.replay(cx));
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
@@ -906,7 +910,7 @@ impl NativeAgent {
true,
cx,
);
- })?;
+ });
thread.update(cx, |thread, cx| {
thread.push_acp_user_block(id, [block], path_style, cx);
@@ -920,7 +924,7 @@ impl NativeAgent {
true,
cx,
);
- })?;
+ });
thread.update(cx, |thread, cx| {
thread.push_acp_agent_block(block, cx);
@@ -941,7 +945,11 @@ impl NativeAgent {
})?;
cx.update(|cx| {
- NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
+ NativeAgentConnection::handle_thread_events(
+ response_stream,
+ acp_thread.downgrade(),
+ cx,
+ )
})
.await
})
@@ -986,7 +994,7 @@ impl NativeAgentConnection {
Ok(stream) => stream,
Err(err) => return Task::ready(Err(err)),
};
- Self::handle_thread_events(response_stream, acp_thread, cx)
+ Self::handle_thread_events(response_stream, acp_thread.downgrade(), cx)
}
fn handle_thread_events(
@@ -1057,6 +1065,11 @@ impl NativeAgentConnection {
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) => {
acp_thread.update(cx, |thread, cx| {
thread.update_retry_status(status, cx)
@@ -1222,7 +1235,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
"zed".into()
}
- fn new_thread(
+ fn new_session(
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
@@ -1249,6 +1262,17 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
.update(cx, |agent, cx| agent.open_thread(session.session_id, cx))
}
+ fn supports_close_session(&self, _cx: &App) -> bool {
+ true
+ }
+
+ fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task<Result<()>> {
+ self.0.update(cx, |agent, _cx| {
+ agent.sessions.remove(session_id);
+ });
+ Task::ready(Ok(()))
+ }
+
fn auth_methods(&self) -> &[acp::AuthMethod] {
&[] // No auth for in-process
}
@@ -1363,7 +1387,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionTruncate {
thread: session.thread.clone(),
- acp_thread: session.acp_thread.clone(),
+ acp_thread: session.acp_thread.downgrade(),
}) as _
})
})
@@ -1551,11 +1575,120 @@ impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
}
}
-pub struct AcpThreadEnvironment {
+pub struct NativeThreadEnvironment {
+ agent: WeakEntity<NativeAgent>,
acp_thread: WeakEntity<AcpThread>,
}
-impl ThreadEnvironment for AcpThreadEnvironment {
+impl NativeThreadEnvironment {
+ pub(crate) fn create_subagent_thread(
+ agent: WeakEntity<NativeAgent>,
+ parent_thread_entity: Entity<Thread>,
+ label: String,
+ initial_prompt: String,
+ timeout: Option<Duration>,
+ allowed_tools: Option<Vec<String>>,
+ cx: &mut App,
+ ) -> Result<Rc<dyn SubagentHandle>> {
+ let parent_thread = parent_thread_entity.read(cx);
+ let current_depth = parent_thread.depth();
+
+ if current_depth >= MAX_SUBAGENT_DEPTH {
+ return Err(anyhow!(
+ "Maximum subagent depth ({}) reached",
+ MAX_SUBAGENT_DEPTH
+ ));
+ }
+
+ let running_count = parent_thread.running_subagent_count();
+ if running_count >= MAX_PARALLEL_SUBAGENTS {
+ return Err(anyhow!(
+ "Maximum parallel subagents ({}) reached. Wait for existing subagents to complete.",
+ MAX_PARALLEL_SUBAGENTS
+ ));
+ }
+
+ let allowed_tools = match allowed_tools {
+ Some(tools) => {
+ let parent_tool_names: std::collections::HashSet<&str> =
+ parent_thread.tools.keys().map(|s| s.as_str()).collect();
+ Some(
+ tools
+ .into_iter()
+ .filter(|t| parent_tool_names.contains(t.as_str()))
+ .collect::<Vec<_>>(),
+ )
+ }
+ None => Some(parent_thread.tools.keys().map(|s| s.to_string()).collect()),
+ };
+
+ let subagent_thread: Entity<Thread> = cx.new(|cx| {
+ let mut thread = Thread::new_subagent(&parent_thread_entity, cx);
+ thread.set_title(label.into(), cx);
+ thread
+ });
+
+ let session_id = subagent_thread.read(cx).id().clone();
+
+ let acp_thread = agent.update(cx, |agent, cx| {
+ agent.register_session(
+ subagent_thread.clone(),
+ allowed_tools
+ .as_ref()
+ .map(|v| v.iter().map(|s| s.as_str()).collect()),
+ cx,
+ )
+ })?;
+
+ parent_thread_entity.update(cx, |parent_thread, _cx| {
+ parent_thread.register_running_subagent(subagent_thread.downgrade())
+ });
+
+ let task = acp_thread.update(cx, |agent, cx| agent.send(vec![initial_prompt.into()], cx));
+
+ let timeout_timer = timeout.map(|d| cx.background_executor().timer(d));
+ let wait_for_prompt_to_complete = cx
+ .background_spawn(async move {
+ if let Some(timer) = timeout_timer {
+ futures::select! {
+ _ = timer.fuse() => SubagentInitialPromptResult::Timeout,
+ _ = task.fuse() => SubagentInitialPromptResult::Completed,
+ }
+ } else {
+ task.await.log_err();
+ SubagentInitialPromptResult::Completed
+ }
+ })
+ .shared();
+
+ let mut user_stop_rx: watch::Receiver<bool> =
+ acp_thread.update(cx, |thread, _| thread.user_stop_receiver());
+
+ let user_cancelled = cx
+ .background_spawn(async move {
+ loop {
+ if *user_stop_rx.borrow() {
+ return;
+ }
+ if user_stop_rx.changed().await.is_err() {
+ std::future::pending::<()>().await;
+ }
+ }
+ })
+ .shared();
+
+ Ok(Rc::new(NativeSubagentHandle {
+ session_id,
+ subagent_thread,
+ parent_thread: parent_thread_entity.downgrade(),
+ acp_thread,
+ wait_for_prompt_to_complete,
+ user_cancelled,
+ }) as _)
+ }
+}
+
+impl ThreadEnvironment for NativeThreadEnvironment {
fn create_terminal(
&self,
command: String,
@@ -1588,6 +1721,98 @@ impl ThreadEnvironment for AcpThreadEnvironment {
Ok(Rc::new(handle) as _)
})
}
+
+ fn create_subagent(
+ &self,
+ parent_thread_entity: Entity<Thread>,
+ label: String,
+ initial_prompt: String,
+ timeout: Option<Duration>,
+ allowed_tools: Option<Vec<String>>,
+ cx: &mut App,
+ ) -> Result<Rc<dyn SubagentHandle>> {
+ Self::create_subagent_thread(
+ self.agent.clone(),
+ parent_thread_entity,
+ label,
+ initial_prompt,
+ timeout,
+ allowed_tools,
+ cx,
+ )
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+enum SubagentInitialPromptResult {
+ Completed,
+ Timeout,
+}
+
+pub struct NativeSubagentHandle {
+ session_id: acp::SessionId,
+ parent_thread: WeakEntity<Thread>,
+ subagent_thread: Entity<Thread>,
+ acp_thread: Entity<AcpThread>,
+ wait_for_prompt_to_complete: Shared<Task<SubagentInitialPromptResult>>,
+ user_cancelled: Shared<Task<()>>,
+}
+
+impl SubagentHandle for NativeSubagentHandle {
+ fn id(&self) -> acp::SessionId {
+ self.session_id.clone()
+ }
+
+ fn wait_for_summary(&self, summary_prompt: String, cx: &AsyncApp) -> Task<Result<String>> {
+ let thread = self.subagent_thread.clone();
+ let acp_thread = self.acp_thread.clone();
+ let wait_for_prompt = self.wait_for_prompt_to_complete.clone();
+
+ let wait_for_summary_task = cx.spawn(async move |cx| {
+ let timed_out = match wait_for_prompt.await {
+ SubagentInitialPromptResult::Completed => false,
+ SubagentInitialPromptResult::Timeout => true,
+ };
+
+ let summary_prompt = if timed_out {
+ thread.update(cx, |thread, cx| thread.cancel(cx)).await;
+ format!("{}\n{}", "The time to complete the task was exceeded. Stop with the task and follow the directions below:", summary_prompt)
+ } else {
+ summary_prompt
+ };
+
+ acp_thread
+ .update(cx, |thread, cx| thread.send(vec![summary_prompt.into()], cx))
+ .await?;
+
+ thread.read_with(cx, |thread, _cx| {
+ thread
+ .last_message()
+ .map(|m| m.to_markdown())
+ .context("No response from subagent")
+ })
+ });
+
+ let user_cancelled = self.user_cancelled.clone();
+ let thread = self.subagent_thread.clone();
+ let subagent_session_id = self.session_id.clone();
+ let parent_thread = self.parent_thread.clone();
+ cx.spawn(async move |cx| {
+ let result = futures::select! {
+ result = wait_for_summary_task.fuse() => result,
+ _ = user_cancelled.fuse() => {
+ thread.update(cx, |thread, cx| thread.cancel(cx).detach());
+ Err(anyhow!("User cancelled"))
+ },
+ };
+ parent_thread
+ .update(cx, |parent_thread, cx| {
+ parent_thread.unregister_running_subagent(&subagent_session_id, cx)
+ })
+ .ok();
+ result
+ })
+ }
}
pub struct AcpTerminalHandle {
@@ -1730,7 +1955,7 @@ mod internal_tests {
// Create a thread/session
let acp_thread = cx
.update(|cx| {
- Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
+ Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
})
.await
.unwrap();
@@ -1808,7 +2033,7 @@ mod internal_tests {
// Create a thread/session
let acp_thread = cx
.update(|cx| {
- Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
+ Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
})
.await
.unwrap();
@@ -1908,7 +2133,7 @@ mod internal_tests {
let acp_thread = cx
.update(|cx| {
- Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
+ Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx)
})
.await
.unwrap();
@@ -2024,7 +2249,7 @@ mod internal_tests {
.update(|cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new("/a"), cx)
+ .new_session(project.clone(), Path::new("/a"), cx)
})
.await
.unwrap();
@@ -2057,11 +2282,12 @@ mod internal_tests {
send.await.unwrap();
cx.run_until_parked();
- // Drop the thread so it can be reloaded from disk.
- cx.update(|_| {
- drop(thread);
- drop(acp_thread);
- });
+ // Close the session so it can be reloaded from disk.
+ cx.update(|cx| connection.clone().close_session(&session_id, cx))
+ .await
+ .unwrap();
+ drop(thread);
+ drop(acp_thread);
agent.read_with(cx, |agent, _| {
assert!(agent.sessions.is_empty());
});
@@ -2130,7 +2356,7 @@ mod internal_tests {
.update(|cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new("/a"), cx)
+ .new_session(project.clone(), Path::new("/a"), cx)
})
.await
.unwrap();
@@ -2163,11 +2389,12 @@ mod internal_tests {
send.await.unwrap();
cx.run_until_parked();
- // Drop the thread so it can be reloaded from disk.
- cx.update(|_| {
- drop(thread);
- drop(acp_thread);
- });
+ // Close the session so it can be reloaded from disk.
+ cx.update(|cx| connection.clone().close_session(&session_id, cx))
+ .await
+ .unwrap();
+ drop(thread);
+ drop(acp_thread);
agent.read_with(cx, |agent, _| {
assert!(agent.sessions.is_empty());
});
@@ -2225,7 +2452,7 @@ mod internal_tests {
.update(|cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new(""), cx)
+ .new_session(project.clone(), Path::new(""), cx)
})
.await
.unwrap();
@@ -2294,11 +2521,12 @@ mod internal_tests {
cx.run_until_parked();
- // Drop the ACP thread, which should cause the session to be dropped as well.
- cx.update(|_| {
- drop(thread);
- drop(acp_thread);
- });
+ // Close the session so it can be reloaded from disk.
+ cx.update(|cx| connection.clone().close_session(&session_id, cx))
+ .await
+ .unwrap();
+ drop(thread);
+ drop(acp_thread);
agent.read_with(cx, |agent, _| {
assert_eq!(agent.sessions.keys().cloned().collect::<Vec<_>>(), []);
});
@@ -26,6 +26,7 @@ pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbThreadMetadata {
pub id: acp::SessionId,
+ pub parent_session_id: Option<acp::SessionId>,
#[serde(alias = "summary")]
pub title: SharedString,
pub updated_at: DateTime<Utc>,
@@ -50,6 +51,8 @@ pub struct DbThread {
pub profile: Option<AgentProfileId>,
#[serde(default)]
pub imported: bool,
+ #[serde(default)]
+ pub subagent_context: Option<crate::SubagentContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -87,6 +90,7 @@ impl SharedThread {
model: self.model,
profile: None,
imported: true,
+ subagent_context: None,
}
}
@@ -260,6 +264,7 @@ impl DbThread {
model: thread.model,
profile: thread.profile,
imported: false,
+ subagent_context: None,
})
}
}
@@ -357,6 +362,13 @@ impl ThreadsDatabase {
"})?()
.map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
+ if let Ok(mut s) = connection.exec(indoc! {"
+ ALTER TABLE threads ADD COLUMN parent_id TEXT
+ "})
+ {
+ s().ok();
+ }
+
let db = Self {
executor,
connection: Arc::new(Mutex::new(connection)),
@@ -381,6 +393,10 @@ impl ThreadsDatabase {
let title = thread.title.to_string();
let updated_at = thread.updated_at.to_rfc3339();
+ let parent_id = thread
+ .subagent_context
+ .as_ref()
+ .map(|ctx| ctx.parent_thread_id.0.clone());
let json_data = serde_json::to_string(&SerializedThread {
thread,
version: DbThread::VERSION,
@@ -392,11 +408,11 @@ impl ThreadsDatabase {
let data_type = DataType::Zstd;
let data = compressed;
- let mut insert = connection.exec_bound::<(Arc<str>, String, String, DataType, Vec<u8>)>(indoc! {"
- INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)
+ let mut insert = connection.exec_bound::<(Arc<str>, Option<Arc<str>>, String, String, DataType, Vec<u8>)>(indoc! {"
+ INSERT OR REPLACE INTO threads (id, parent_id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?)
"})?;
- insert((id.0, title, updated_at, data_type, data))?;
+ insert((id.0, parent_id, title, updated_at, data_type, data))?;
Ok(())
}
@@ -407,17 +423,18 @@ impl ThreadsDatabase {
self.executor.spawn(async move {
let connection = connection.lock();
- let mut select =
- connection.select_bound::<(), (Arc<str>, String, String)>(indoc! {"
- SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC
+ let mut select = connection
+ .select_bound::<(), (Arc<str>, Option<Arc<str>>, String, String)>(indoc! {"
+ SELECT id, parent_id, summary, updated_at FROM threads ORDER BY updated_at DESC
"})?;
let rows = select(())?;
let mut threads = Vec::new();
- for (id, summary, updated_at) in rows {
+ for (id, parent_id, summary, updated_at) in rows {
threads.push(DbThreadMetadata {
id: acp::SessionId::new(id),
+ parent_session_id: parent_id.map(acp::SessionId::new),
title: summary.into(),
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
});
@@ -552,6 +569,7 @@ mod tests {
model: None,
profile: None,
imported: false,
+ subagent_context: None,
}
}
@@ -618,4 +636,81 @@ mod tests {
Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap()
);
}
+
+ #[test]
+ fn test_subagent_context_defaults_to_none() {
+ let json = r#"{
+ "title": "Old Thread",
+ "messages": [],
+ "updated_at": "2024-01-01T00:00:00Z"
+ }"#;
+
+ let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize");
+
+ assert!(
+ db_thread.subagent_context.is_none(),
+ "Legacy threads without subagent_context should default to None"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) {
+ let database = ThreadsDatabase::new(cx.executor()).unwrap();
+
+ let parent_id = session_id("parent-thread");
+ let child_id = session_id("child-thread");
+
+ let mut child_thread = make_thread(
+ "Subagent Thread",
+ Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
+ );
+ child_thread.subagent_context = Some(crate::SubagentContext {
+ parent_thread_id: parent_id.clone(),
+ depth: 2,
+ });
+
+ database
+ .save_thread(child_id.clone(), child_thread)
+ .await
+ .unwrap();
+
+ let loaded = database
+ .load_thread(child_id)
+ .await
+ .unwrap()
+ .expect("thread should exist");
+
+ let context = loaded
+ .subagent_context
+ .expect("subagent_context should be restored");
+ assert_eq!(context.parent_thread_id, parent_id);
+ assert_eq!(context.depth, 2);
+ }
+
+ #[gpui::test]
+ async fn test_non_subagent_thread_has_no_subagent_context(cx: &mut TestAppContext) {
+ let database = ThreadsDatabase::new(cx.executor()).unwrap();
+
+ let thread_id = session_id("regular-thread");
+ let thread = make_thread(
+ "Regular Thread",
+ Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
+ );
+
+ database
+ .save_thread(thread_id.clone(), thread)
+ .await
+ .unwrap();
+
+ let loaded = database
+ .load_thread(thread_id)
+ .await
+ .unwrap()
+ .expect("thread should exist");
+
+ assert!(
+ loaded.subagent_context.is_none(),
+ "Regular threads should have no subagent_context"
+ );
+ }
}
@@ -1,15 +1,14 @@
use super::*;
use crate::{AgentTool, EditFileTool, ReadFileTool};
use acp_thread::UserMessageId;
-use action_log::ActionLog;
use fs::FakeFs;
use language_model::{
- LanguageModelCompletionEvent, LanguageModelToolUse, MessageContent, StopReason,
+ LanguageModelCompletionEvent, LanguageModelToolUse, StopReason,
fake_provider::FakeLanguageModel,
};
use prompt_store::ProjectContext;
use serde_json::json;
-use std::{collections::BTreeMap, sync::Arc, time::Duration};
+use std::{sync::Arc, time::Duration};
use util::path;
#[gpui::test]
@@ -50,17 +49,23 @@ async fn test_edit_file_tool_in_thread_context(cx: &mut TestAppContext) {
);
// Add just the tools we need for this test
let language_registry = project.read(cx).languages().clone();
- thread.add_tool(crate::ReadFileTool::new(
- cx.weak_entity(),
- project.clone(),
- thread.action_log().clone(),
- ));
- thread.add_tool(crate::EditFileTool::new(
- project.clone(),
- cx.weak_entity(),
- language_registry,
- crate::Templates::new(),
- ));
+ thread.add_tool(
+ crate::ReadFileTool::new(
+ cx.weak_entity(),
+ project.clone(),
+ thread.action_log().clone(),
+ ),
+ None,
+ );
+ thread.add_tool(
+ crate::EditFileTool::new(
+ project.clone(),
+ cx.weak_entity(),
+ language_registry,
+ crate::Templates::new(),
+ ),
+ None,
+ );
thread
});
@@ -203,417 +208,3 @@ async fn test_edit_file_tool_in_thread_context(cx: &mut TestAppContext) {
);
});
}
-
-#[gpui::test]
-async fn test_subagent_uses_read_file_tool(cx: &mut TestAppContext) {
- // This test verifies that subagents can successfully use the read_file tool
- // through the full thread flow, and that tools are properly rebound to use
- // the subagent's thread ID instead of the parent's.
- super::init_test(cx);
- super::always_allow_tools(cx);
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- "src": {
- "lib.rs": "pub fn hello() -> &'static str {\n \"Hello from lib!\"\n}\n"
- }
- }),
- )
- .await;
-
- let project = project::Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx));
- let model = Arc::new(FakeLanguageModel::default());
- let fake_model = model.as_fake();
-
- // Create subagent context
- let subagent_context = crate::SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("subagent-tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize what you found".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- // Create parent tools that will be passed to the subagent
- // This simulates how the subagent_tool passes tools to new_subagent
- let parent_tools: BTreeMap<gpui::SharedString, std::sync::Arc<dyn crate::AnyAgentTool>> = {
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
- // Create a "fake" parent thread reference - this should get rebound
- let fake_parent_thread = cx.new(|cx| {
- crate::Thread::new(
- project.clone(),
- cx.new(|_cx| ProjectContext::default()),
- cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx)),
- crate::Templates::new(),
- Some(model.clone()),
- cx,
- )
- });
- let mut tools: BTreeMap<gpui::SharedString, std::sync::Arc<dyn crate::AnyAgentTool>> =
- BTreeMap::new();
- tools.insert(
- ReadFileTool::NAME.into(),
- crate::ReadFileTool::new(fake_parent_thread.downgrade(), project.clone(), action_log)
- .erase(),
- );
- tools
- };
-
- // Create subagent - tools should be rebound to use subagent's thread
- let subagent = cx.new(|cx| {
- crate::Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- crate::Templates::new(),
- model.clone(),
- subagent_context,
- parent_tools,
- cx,
- )
- });
-
- // Get the subagent's thread ID
- let _subagent_thread_id = subagent.read_with(cx, |thread, _| thread.id().to_string());
-
- // Verify the subagent has the read_file tool
- subagent.read_with(cx, |thread, _| {
- assert!(
- thread.has_registered_tool(ReadFileTool::NAME),
- "subagent should have read_file tool"
- );
- });
-
- // Submit a user message to the subagent
- subagent
- .update(cx, |thread, cx| {
- thread.submit_user_message("Read the file src/lib.rs", cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- // Simulate the model calling the read_file tool
- let read_tool_use = LanguageModelToolUse {
- id: "read_tool_1".into(),
- name: ReadFileTool::NAME.into(),
- raw_input: json!({"path": "project/src/lib.rs"}).to_string(),
- input: json!({"path": "project/src/lib.rs"}),
- is_input_complete: true,
- thought_signature: None,
- };
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(read_tool_use));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
-
- // Wait for the tool to complete and the model to be called again with tool results
- let deadline = std::time::Instant::now() + Duration::from_secs(5);
- while fake_model.pending_completions().is_empty() {
- if std::time::Instant::now() >= deadline {
- panic!("Timed out waiting for model to be called after read_file tool completion");
- }
- cx.run_until_parked();
- cx.background_executor
- .timer(Duration::from_millis(10))
- .await;
- }
-
- // Verify the tool result was sent back to the model
- let pending = fake_model.pending_completions();
- assert!(
- !pending.is_empty(),
- "Model should have been called with tool result"
- );
-
- let last_request = pending.last().unwrap();
- let tool_result = last_request.messages.iter().find_map(|m| {
- m.content.iter().find_map(|c| match c {
- MessageContent::ToolResult(result) => Some(result),
- _ => None,
- })
- });
- assert!(
- tool_result.is_some(),
- "Tool result should be in the messages sent back to the model"
- );
-
- // Verify the tool result contains the file content
- let result = tool_result.unwrap();
- let result_text = match &result.content {
- language_model::LanguageModelToolResultContent::Text(text) => text.to_string(),
- _ => panic!("expected text content in tool result"),
- };
- assert!(
- result_text.contains("Hello from lib!"),
- "Tool result should contain file content, got: {}",
- result_text
- );
-
- // Verify the subagent is ready for more input (tool completed, model called again)
- // This test verifies the subagent can successfully use read_file tool.
- // The summary flow is tested separately in test_subagent_returns_summary_on_completion.
-}
-
-#[gpui::test]
-async fn test_subagent_uses_edit_file_tool(cx: &mut TestAppContext) {
- // This test verifies that subagents can successfully use the edit_file tool
- // through the full thread flow, including the edit agent's model request.
- // It also verifies that the edit agent uses the subagent's thread ID, not the parent's.
- super::init_test(cx);
- super::always_allow_tools(cx);
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/project"),
- json!({
- "src": {
- "config.rs": "pub const VERSION: &str = \"1.0.0\";\n"
- }
- }),
- )
- .await;
-
- let project = project::Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx));
- let model = Arc::new(FakeLanguageModel::default());
- let fake_model = model.as_fake();
-
- // Create a "parent" thread to simulate the real scenario where tools are inherited
- let parent_thread = cx.new(|cx| {
- crate::Thread::new(
- project.clone(),
- cx.new(|_cx| ProjectContext::default()),
- cx.new(|cx| crate::ContextServerRegistry::new(context_server_store.clone(), cx)),
- crate::Templates::new(),
- Some(model.clone()),
- cx,
- )
- });
- let parent_thread_id = parent_thread.read_with(cx, |thread, _| thread.id().to_string());
-
- // Create parent tools that reference the parent thread
- let parent_tools: BTreeMap<gpui::SharedString, std::sync::Arc<dyn crate::AnyAgentTool>> = {
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
- let language_registry = project.read_with(cx, |p, _| p.languages().clone());
- let mut tools: BTreeMap<gpui::SharedString, std::sync::Arc<dyn crate::AnyAgentTool>> =
- BTreeMap::new();
- tools.insert(
- ReadFileTool::NAME.into(),
- crate::ReadFileTool::new(parent_thread.downgrade(), project.clone(), action_log)
- .erase(),
- );
- tools.insert(
- EditFileTool::NAME.into(),
- crate::EditFileTool::new(
- project.clone(),
- parent_thread.downgrade(),
- language_registry,
- crate::Templates::new(),
- )
- .erase(),
- );
- tools
- };
-
- // Create subagent context
- let subagent_context = crate::SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("subagent-tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize what you changed".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- // Create subagent - tools should be rebound to use subagent's thread
- let subagent = cx.new(|cx| {
- crate::Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- crate::Templates::new(),
- model.clone(),
- subagent_context,
- parent_tools,
- cx,
- )
- });
-
- // Get the subagent's thread ID - it should be different from parent
- let subagent_thread_id = subagent.read_with(cx, |thread, _| thread.id().to_string());
- assert_ne!(
- parent_thread_id, subagent_thread_id,
- "Subagent should have a different thread ID than parent"
- );
-
- // Verify the subagent has the tools
- subagent.read_with(cx, |thread, _| {
- assert!(
- thread.has_registered_tool(ReadFileTool::NAME),
- "subagent should have read_file tool"
- );
- assert!(
- thread.has_registered_tool(EditFileTool::NAME),
- "subagent should have edit_file tool"
- );
- });
-
- // Submit a user message to the subagent
- subagent
- .update(cx, |thread, cx| {
- thread.submit_user_message("Update the version in config.rs to 2.0.0", cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- // First, model calls read_file to see the current content
- let read_tool_use = LanguageModelToolUse {
- id: "read_tool_1".into(),
- name: ReadFileTool::NAME.into(),
- raw_input: json!({"path": "project/src/config.rs"}).to_string(),
- input: json!({"path": "project/src/config.rs"}),
- is_input_complete: true,
- thought_signature: None,
- };
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(read_tool_use));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
-
- // Wait for the read tool to complete and model to be called again
- let deadline = std::time::Instant::now() + Duration::from_secs(5);
- while fake_model.pending_completions().is_empty() {
- if std::time::Instant::now() >= deadline {
- panic!("Timed out waiting for model to be called after read_file tool");
- }
- cx.run_until_parked();
- cx.background_executor
- .timer(Duration::from_millis(10))
- .await;
- }
-
- // Model responds and calls edit_file
- fake_model.send_last_completion_stream_text_chunk("I'll update the version now.");
- let edit_tool_use = LanguageModelToolUse {
- id: "edit_tool_1".into(),
- name: EditFileTool::NAME.into(),
- raw_input: json!({
- "display_description": "Update version to 2.0.0",
- "path": "project/src/config.rs",
- "mode": "edit"
- })
- .to_string(),
- input: json!({
- "display_description": "Update version to 2.0.0",
- "path": "project/src/config.rs",
- "mode": "edit"
- }),
- is_input_complete: true,
- thought_signature: None,
- };
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(edit_tool_use));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
-
- // The edit_file tool creates an EditAgent which makes its own model request.
- // Wait for that request.
- let deadline = std::time::Instant::now() + Duration::from_secs(5);
- while fake_model.pending_completions().is_empty() {
- if std::time::Instant::now() >= deadline {
- panic!(
- "Timed out waiting for edit agent completion request in subagent. Pending: {}",
- fake_model.pending_completions().len()
- );
- }
- cx.run_until_parked();
- cx.background_executor
- .timer(Duration::from_millis(10))
- .await;
- }
-
- // Verify the edit agent's request uses the SUBAGENT's thread ID, not the parent's
- let pending = fake_model.pending_completions();
- let edit_agent_request = pending.last().unwrap();
- let edit_agent_thread_id = edit_agent_request.thread_id.as_ref().unwrap();
- std::assert_eq!(
- edit_agent_thread_id,
- &subagent_thread_id,
- "Edit agent should use subagent's thread ID, not parent's. Got: {}, expected: {}",
- edit_agent_thread_id,
- subagent_thread_id
- );
- std::assert_ne!(
- edit_agent_thread_id,
- &parent_thread_id,
- "Edit agent should NOT use parent's thread ID"
- );
-
- // Send the edit agent's response with the XML format it expects
- let edit_response = "<old_text>pub const VERSION: &str = \"1.0.0\";</old_text>\n<new_text>pub const VERSION: &str = \"2.0.0\";</new_text>";
- fake_model.send_last_completion_stream_text_chunk(edit_response);
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
-
- // Wait for the edit to complete and the thread to call the model again with tool results
- let deadline = std::time::Instant::now() + Duration::from_secs(5);
- while fake_model.pending_completions().is_empty() {
- if std::time::Instant::now() >= deadline {
- panic!("Timed out waiting for model to be called after edit completion in subagent");
- }
- cx.run_until_parked();
- cx.background_executor
- .timer(Duration::from_millis(10))
- .await;
- }
-
- // Verify the file was edited
- let file_content = fs
- .load(path!("/project/src/config.rs").as_ref())
- .await
- .expect("file should exist");
- assert!(
- file_content.contains("2.0.0"),
- "File should have been edited to contain new version. Content: {}",
- file_content
- );
- assert!(
- !file_content.contains("1.0.0"),
- "Old version should be replaced. Content: {}",
- file_content
- );
-
- // Verify the tool result was sent back to the model
- let pending = fake_model.pending_completions();
- assert!(
- !pending.is_empty(),
- "Model should have been called with tool result"
- );
-
- let last_request = pending.last().unwrap();
- let has_tool_result = last_request.messages.iter().any(|m| {
- m.content
- .iter()
- .any(|c| matches!(c, MessageContent::ToolResult(_)))
- });
- assert!(
- has_tool_result,
- "Tool result should be in the messages sent back to the model"
- );
-}
@@ -155,8 +155,51 @@ impl crate::TerminalHandle for FakeTerminalHandle {
}
}
+struct FakeSubagentHandle {
+ session_id: acp::SessionId,
+ wait_for_summary_task: Shared<Task<String>>,
+}
+
+impl FakeSubagentHandle {
+ fn new_never_completes(cx: &App) -> Self {
+ Self {
+ session_id: acp::SessionId::new("subagent-id"),
+ wait_for_summary_task: cx.background_spawn(std::future::pending()).shared(),
+ }
+ }
+}
+
+impl SubagentHandle for FakeSubagentHandle {
+ fn id(&self) -> acp::SessionId {
+ self.session_id.clone()
+ }
+
+ fn wait_for_summary(&self, _summary_prompt: String, cx: &AsyncApp) -> Task<Result<String>> {
+ let task = self.wait_for_summary_task.clone();
+ cx.background_spawn(async move { Ok(task.await) })
+ }
+}
+
+#[derive(Default)]
struct FakeThreadEnvironment {
- handle: Rc<FakeTerminalHandle>,
+ terminal_handle: Option<Rc<FakeTerminalHandle>>,
+ subagent_handle: Option<Rc<FakeSubagentHandle>>,
+}
+
+impl FakeThreadEnvironment {
+ pub fn with_terminal(self, terminal_handle: FakeTerminalHandle) -> Self {
+ Self {
+ terminal_handle: Some(terminal_handle.into()),
+ ..self
+ }
+ }
+
+ pub fn with_subagent(self, subagent_handle: FakeSubagentHandle) -> Self {
+ Self {
+ subagent_handle: Some(subagent_handle.into()),
+ ..self
+ }
+ }
}
impl crate::ThreadEnvironment for FakeThreadEnvironment {
@@ -167,7 +210,27 @@ impl crate::ThreadEnvironment for FakeThreadEnvironment {
_output_byte_limit: Option<u64>,
_cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn crate::TerminalHandle>>> {
- Task::ready(Ok(self.handle.clone() as Rc<dyn crate::TerminalHandle>))
+ let handle = self
+ .terminal_handle
+ .clone()
+ .expect("Terminal handle not available on FakeThreadEnvironment");
+ Task::ready(Ok(handle as Rc<dyn crate::TerminalHandle>))
+ }
+
+ fn create_subagent(
+ &self,
+ _parent_thread: Entity<Thread>,
+ _label: String,
+ _initial_prompt: String,
+ _timeout_ms: Option<Duration>,
+ _allowed_tools: Option<Vec<String>>,
+ _cx: &mut App,
+ ) -> Result<Rc<dyn SubagentHandle>> {
+ Ok(self
+ .subagent_handle
+ .clone()
+ .expect("Subagent handle not available on FakeThreadEnvironment")
+ as Rc<dyn SubagentHandle>)
}
}
@@ -200,6 +263,18 @@ impl crate::ThreadEnvironment for MultiTerminalEnvironment {
self.handles.borrow_mut().push(handle.clone());
Task::ready(Ok(handle as Rc<dyn crate::TerminalHandle>))
}
+
+ fn create_subagent(
+ &self,
+ _parent_thread: Entity<Thread>,
+ _label: String,
+ _initial_prompt: String,
+ _timeout: Option<Duration>,
+ _allowed_tools: Option<Vec<String>>,
+ _cx: &mut App,
+ ) -> Result<Rc<dyn SubagentHandle>> {
+ unimplemented!()
+ }
}
fn always_allow_tools(cx: &mut TestAppContext) {
@@ -228,14 +303,8 @@ async fn test_echo(cx: &mut TestAppContext) {
let events = events.collect().await;
thread.update(cx, |thread, _cx| {
- assert_eq!(
- thread.last_message().unwrap().to_markdown(),
- indoc! {"
- ## Assistant
-
- Hello
- "}
- )
+ assert_eq!(thread.last_message().unwrap().role(), Role::Assistant);
+ assert_eq!(thread.last_message().unwrap().to_markdown(), "Hello\n")
});
assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
}
@@ -248,10 +317,10 @@ async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::TerminalTool::new(project, environment));
@@ -315,10 +384,10 @@ async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAp
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::TerminalTool::new(project, environment));
@@ -387,11 +456,10 @@ async fn test_thinking(cx: &mut TestAppContext) {
let events = events.collect().await;
thread.update(cx, |thread, _cx| {
+ assert_eq!(thread.last_message().unwrap().role(), Role::Assistant);
assert_eq!(
thread.last_message().unwrap().to_markdown(),
indoc! {"
- ## Assistant
-
<think>Think</think>
Hello
"}
@@ -413,7 +481,7 @@ async fn test_system_prompt(cx: &mut TestAppContext) {
project_context.update(cx, |project_context, _cx| {
project_context.shell = "test-shell".into()
});
- thread.update(cx, |thread, _| thread.add_tool(EchoTool));
+ thread.update(cx, |thread, _| thread.add_tool(EchoTool, None));
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["abc"], cx)
@@ -549,7 +617,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) {
cx.run_until_parked();
// Simulate a tool call and verify that the latest tool result is cached
- thread.update(cx, |thread, _| thread.add_tool(EchoTool));
+ thread.update(cx, |thread, _| thread.add_tool(EchoTool, None));
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["Use the echo tool"], cx)
@@ -635,7 +703,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
// Test a tool call that's likely to complete *before* streaming stops.
let events = thread
.update(cx, |thread, cx| {
- thread.add_tool(EchoTool);
+ thread.add_tool(EchoTool, None);
thread.send(
UserMessageId::new(),
["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."],
@@ -651,7 +719,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
let events = thread
.update(cx, |thread, cx| {
thread.remove_tool(&EchoTool::NAME);
- thread.add_tool(DelayTool);
+ thread.add_tool(DelayTool, None);
thread.send(
UserMessageId::new(),
[
@@ -695,7 +763,7 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
// Test a tool call that's likely to complete *before* streaming stops.
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(WordListTool);
+ thread.add_tool(WordListTool, None);
thread.send(UserMessageId::new(), ["Test the word_list tool."], cx)
})
.unwrap();
@@ -746,7 +814,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(ToolRequiringPermission);
+ thread.add_tool(ToolRequiringPermission, None);
thread.send(UserMessageId::new(), ["abc"], cx)
})
.unwrap();
@@ -1087,7 +1155,7 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
// Test concurrent tool calls with different delay times
let events = thread
.update(cx, |thread, cx| {
- thread.add_tool(DelayTool);
+ thread.add_tool(DelayTool, None);
thread.send(
UserMessageId::new(),
[
@@ -1132,9 +1200,9 @@ async fn test_profiles(cx: &mut TestAppContext) {
let fake_model = model.as_fake();
thread.update(cx, |thread, _cx| {
- thread.add_tool(DelayTool);
- thread.add_tool(EchoTool);
- thread.add_tool(InfiniteTool);
+ thread.add_tool(DelayTool, None);
+ thread.add_tool(EchoTool, None);
+ thread.add_tool(InfiniteTool, None);
});
// Override profiles and wait for settings to be loaded.
@@ -1300,7 +1368,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
// Send again after adding the echo tool, ensuring the name collision is resolved.
let events = thread.update(cx, |thread, cx| {
- thread.add_tool(EchoTool);
+ thread.add_tool(EchoTool, None);
thread.send(UserMessageId::new(), ["Go"], cx).unwrap()
});
cx.run_until_parked();
@@ -1409,11 +1477,11 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
thread.update(cx, |thread, cx| {
thread.set_profile(AgentProfileId("test".into()), cx);
- thread.add_tool(EchoTool);
- thread.add_tool(DelayTool);
- thread.add_tool(WordListTool);
- thread.add_tool(ToolRequiringPermission);
- thread.add_tool(InfiniteTool);
+ thread.add_tool(EchoTool, None);
+ thread.add_tool(DelayTool, None);
+ thread.add_tool(WordListTool, None);
+ thread.add_tool(ToolRequiringPermission, None);
+ thread.add_tool(InfiniteTool, None);
});
// Set up multiple context servers with some overlapping tool names
@@ -1543,8 +1611,8 @@ async fn test_cancellation(cx: &mut TestAppContext) {
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(InfiniteTool);
- thread.add_tool(EchoTool);
+ thread.add_tool(InfiniteTool, None);
+ thread.add_tool(EchoTool, None);
thread.send(
UserMessageId::new(),
["Call the echo tool, then call the infinite tool, then explain their output"],
@@ -1628,17 +1696,17 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext
always_allow_tools(cx);
let fake_model = model.as_fake();
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(crate::TerminalTool::new(
- thread.project().clone(),
- environment,
- ));
+ thread.add_tool(
+ crate::TerminalTool::new(thread.project().clone(), environment),
+ None,
+ );
thread.send(UserMessageId::new(), ["run a command"], cx)
})
.unwrap();
@@ -1732,7 +1800,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(tool);
+ thread.add_tool(tool, None);
thread.send(
UserMessageId::new(),
["call the cancellation aware tool"],
@@ -1910,18 +1978,18 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) {
always_allow_tools(cx);
let fake_model = model.as_fake();
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
let message_id = UserMessageId::new();
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(crate::TerminalTool::new(
- thread.project().clone(),
- environment,
- ));
+ thread.add_tool(
+ crate::TerminalTool::new(thread.project().clone(), environment),
+ None,
+ );
thread.send(message_id.clone(), ["run a command"], cx)
})
.unwrap();
@@ -1982,10 +2050,10 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(crate::TerminalTool::new(
- thread.project().clone(),
- environment.clone(),
- ));
+ thread.add_tool(
+ crate::TerminalTool::new(thread.project().clone(), environment.clone()),
+ None,
+ );
thread.send(UserMessageId::new(), ["run multiple commands"], cx)
})
.unwrap();
@@ -2088,17 +2156,17 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon
always_allow_tools(cx);
let fake_model = model.as_fake();
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(crate::TerminalTool::new(
- thread.project().clone(),
- environment,
- ));
+ thread.add_tool(
+ crate::TerminalTool::new(thread.project().clone(), environment),
+ None,
+ );
thread.send(UserMessageId::new(), ["run a command"], cx)
})
.unwrap();
@@ -2182,17 +2250,17 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
always_allow_tools(cx);
let fake_model = model.as_fake();
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
+ let handle = environment.terminal_handle.clone().unwrap();
let mut events = thread
.update(cx, |thread, cx| {
- thread.add_tool(crate::TerminalTool::new(
- thread.project().clone(),
- environment,
- ));
+ thread.add_tool(
+ crate::TerminalTool::new(thread.project().clone(), environment),
+ None,
+ );
thread.send(UserMessageId::new(), ["run a command with timeout"], cx)
})
.unwrap();
@@ -2673,8 +2741,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
let _events = thread
.update(cx, |thread, cx| {
- thread.add_tool(ToolRequiringPermission);
- thread.add_tool(EchoTool);
+ thread.add_tool(ToolRequiringPermission, None);
+ thread.add_tool(EchoTool, None);
thread.send(UserMessageId::new(), ["Hey!"], cx)
})
.unwrap();
@@ -2788,7 +2856,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
// Create a thread using new_thread
let connection_rc = Rc::new(connection.clone());
let acp_thread = cx
- .update(|cx| connection_rc.new_thread(project, cwd, cx))
+ .update(|cx| connection_rc.new_session(project, cwd, cx))
.await
.expect("new_thread should succeed");
@@ -2855,9 +2923,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
cx.update(|cx| connection.cancel(&session_id, cx));
request.await.expect("prompt should fail gracefully");
- // Ensure that dropping the ACP thread causes the native thread to be
- // dropped as well.
- cx.update(|_| drop(acp_thread));
+ // Explicitly close the session and drop the ACP thread.
+ cx.update(|cx| Rc::new(connection.clone()).close_session(&session_id, cx))
+ .await
+ .unwrap();
+ drop(acp_thread);
let result = cx
.update(|cx| {
connection.prompt(
@@ -2878,7 +2948,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
- thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool));
+ thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool, None));
let fake_model = model.as_fake();
let mut events = thread
@@ -3080,7 +3150,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
let events = thread
.update(cx, |thread, cx| {
- thread.add_tool(EchoTool);
+ thread.add_tool(EchoTool, None);
thread.send(UserMessageId::new(), ["Call the echo tool!"], cx)
})
.unwrap();
@@ -3652,10 +3722,9 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
// Test 1: Deny rule blocks command
{
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
@@ -3704,10 +3773,10 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
// Test 2: Allow rule skips confirmation (and overrides default_mode: Deny)
{
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_with_immediate_exit(cx, 0)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default()
+ .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
+ }));
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
@@ -3762,10 +3831,10 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
// Test 3: always_allow_tool_actions=true overrides always_confirm patterns
{
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_with_immediate_exit(cx, 0)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default()
+ .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
+ }));
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
@@ -3808,10 +3877,10 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
// Test 4: always_allow_tool_actions=true overrides default_mode: Deny
{
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_with_immediate_exit(cx, 0)));
- let environment = Rc::new(FakeThreadEnvironment {
- handle: handle.clone(),
- });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default()
+ .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
+ }));
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
@@ -3868,8 +3937,9 @@ async fn test_subagent_tool_is_present_when_feature_flag_enabled(cx: &mut TestAp
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment { handle });
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
let thread = cx.new(|cx| {
let mut thread = Thread::new(
@@ -3880,7 +3950,7 @@ async fn test_subagent_tool_is_present_when_feature_flag_enabled(cx: &mut TestAp
Some(model),
cx,
);
- thread.add_default_tools(environment, cx);
+ thread.add_default_tools(None, environment, cx);
thread
});
@@ -3893,7 +3963,7 @@ async fn test_subagent_tool_is_present_when_feature_flag_enabled(cx: &mut TestAp
}
#[gpui::test]
-async fn test_subagent_thread_inherits_parent_model(cx: &mut TestAppContext) {
+async fn test_subagent_thread_inherits_parent_thread_properties(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
@@ -3909,31 +3979,29 @@ async fn test_subagent_thread_inherits_parent_model(cx: &mut TestAppContext) {
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
+ let parent_thread = cx.new(|cx| {
+ Thread::new(
project.clone(),
project_context,
context_server_registry,
Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
+ Some(model.clone()),
cx,
)
});
- subagent.read_with(cx, |thread, _| {
- assert!(thread.is_subagent());
- assert_eq!(thread.depth(), 1);
- assert!(thread.model().is_some());
+ let subagent_thread = cx.new(|cx| Thread::new_subagent(&parent_thread, cx));
+ subagent_thread.read_with(cx, |subagent_thread, cx| {
+ assert!(subagent_thread.is_subagent());
+ assert_eq!(subagent_thread.depth(), 1);
+ assert_eq!(
+ subagent_thread.model().map(|model| model.id()),
+ Some(model.id())
+ );
+ assert_eq!(
+ subagent_thread.parent_thread_id(),
+ Some(parent_thread.read(cx).id().clone())
+ );
});
}
@@ -3953,34 +4021,32 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
+ }));
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: MAX_SUBAGENT_DEPTH,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment { handle });
-
- let deep_subagent = cx.new(|cx| {
- let mut thread = Thread::new_subagent(
+ let deep_parent_thread = cx.new(|cx| {
+ let mut thread = Thread::new(
project.clone(),
project_context,
context_server_registry,
Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
+ Some(model.clone()),
cx,
);
- thread.add_default_tools(environment, cx);
+ thread.set_subagent_context(SubagentContext {
+ parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
+ depth: MAX_SUBAGENT_DEPTH - 1,
+ });
+ thread
+ });
+ let deep_subagent_thread = cx.new(|cx| {
+ let mut thread = Thread::new_subagent(&deep_parent_thread, cx);
+ thread.add_default_tools(None, environment, cx);
thread
});
- deep_subagent.read_with(cx, |thread, _| {
+ deep_subagent_thread.read_with(cx, |thread, _| {
assert_eq!(thread.depth(), MAX_SUBAGENT_DEPTH);
assert!(
!thread.has_registered_tool(SubagentTool::NAME),
@@ -3989,209 +4055,6 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont
});
}
-#[gpui::test]
-async fn test_subagent_receives_task_prompt(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize your work".to_string(),
- context_low_prompt: "Context low, wrap up".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
-
- let task_prompt = "Find all TODO comments in the codebase";
- subagent
- .update(cx, |thread, cx| thread.submit_user_message(task_prompt, cx))
- .unwrap();
- cx.run_until_parked();
-
- let pending = fake_model.pending_completions();
- assert_eq!(pending.len(), 1, "should have one pending completion");
-
- let messages = &pending[0].messages;
- let user_messages: Vec<_> = messages
- .iter()
- .filter(|m| m.role == language_model::Role::User)
- .collect();
- assert_eq!(user_messages.len(), 1, "should have one user message");
-
- let content = &user_messages[0].content[0];
- assert!(
- content.to_str().unwrap().contains("TODO"),
- "task prompt should be in user message"
- );
-}
-
-#[gpui::test]
-async fn test_subagent_returns_summary_on_completion(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Please summarize what you found".to_string(),
- context_low_prompt: "Context low, wrap up".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
-
- subagent
- .update(cx, |thread, cx| {
- thread.submit_user_message("Do some work", cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- fake_model.send_last_completion_stream_text_chunk("I did the work");
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
-
- subagent
- .update(cx, |thread, cx| thread.request_final_summary(cx))
- .unwrap();
- cx.run_until_parked();
-
- let pending = fake_model.pending_completions();
- assert!(
- !pending.is_empty(),
- "should have pending completion for summary"
- );
-
- let messages = &pending.last().unwrap().messages;
- let user_messages: Vec<_> = messages
- .iter()
- .filter(|m| m.role == language_model::Role::User)
- .collect();
-
- let last_user = user_messages.last().unwrap();
- assert!(
- last_user.content[0].to_str().unwrap().contains("summarize"),
- "summary prompt should be sent"
- );
-}
-
-#[gpui::test]
-async fn test_allowed_tools_restricts_subagent_capabilities(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(path!("/test"), json!({})).await;
- let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
- let model = Arc::new(FakeLanguageModel::default());
-
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let subagent = cx.new(|cx| {
- let mut thread = Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
- cx,
- );
- thread.add_tool(EchoTool);
- thread.add_tool(DelayTool);
- thread.add_tool(WordListTool);
- thread
- });
-
- subagent.read_with(cx, |thread, _| {
- assert!(thread.has_registered_tool("echo"));
- assert!(thread.has_registered_tool("delay"));
- assert!(thread.has_registered_tool("word_list"));
- });
-
- let allowed: collections::HashSet<gpui::SharedString> =
- vec!["echo".into()].into_iter().collect();
-
- subagent.update(cx, |thread, _cx| {
- thread.restrict_tools(&allowed);
- });
-
- subagent.read_with(cx, |thread, _| {
- assert!(
- thread.has_registered_tool("echo"),
- "echo should still be available"
- );
- assert!(
- !thread.has_registered_tool("delay"),
- "delay should be removed"
- );
- assert!(
- !thread.has_registered_tool("word_list"),
- "word_list should be removed"
- );
- });
-}
-
#[gpui::test]
async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
init_test(cx);
@@ -4220,33 +4083,16 @@ async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
)
});
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
+ let subagent = cx.new(|cx| Thread::new_subagent(&parent, cx));
parent.update(cx, |thread, _cx| {
thread.register_running_subagent(subagent.downgrade());
});
subagent
- .update(cx, |thread, cx| thread.submit_user_message("Do work", cx))
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Do work".to_string()], cx)
+ })
.unwrap();
cx.run_until_parked();
@@ -4285,6 +4131,9 @@ async fn test_subagent_tool_cancellation(cx: &mut TestAppContext) {
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
+ let environment = Rc::new(cx.update(|cx| {
+ FakeThreadEnvironment::default().with_subagent(FakeSubagentHandle::new_never_completes(cx))
+ }));
let parent = cx.new(|cx| {
Thread::new(
@@ -4298,7 +4147,7 @@ async fn test_subagent_tool_cancellation(cx: &mut TestAppContext) {
});
#[allow(clippy::arc_with_non_send_sync)]
- let tool = Arc::new(SubagentTool::new(parent.downgrade(), 0));
+ let tool = Arc::new(SubagentTool::new(parent.downgrade(), environment));
let (event_stream, _rx, mut cancellation_tx) =
crate::ToolCallEventStream::test_with_cancellation();
@@ -4310,7 +4159,6 @@ async fn test_subagent_tool_cancellation(cx: &mut TestAppContext) {
label: "Long running task".to_string(),
task_prompt: "Do a very long task that takes forever".to_string(),
summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
timeout_ms: None,
allowed_tools: None,
},
@@ -4343,405 +4191,286 @@ async fn test_subagent_tool_cancellation(cx: &mut TestAppContext) {
}
#[gpui::test]
-async fn test_subagent_model_error_returned_as_tool_error(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
+async fn test_thread_environment_max_parallel_subagents_enforced(cx: &mut TestAppContext) {
+ init_test(cx);
+ always_allow_tools(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["subagents".to_string()]);
});
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
+ 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 project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
+ cx.update(LanguageModelRegistry::test);
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let native_agent = NativeAgent::new(
+ project.clone(),
+ thread_store,
+ Templates::new(),
+ None,
+ fs,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let parent_thread = cx.new(|cx| {
+ Thread::new(
project.clone(),
project_context,
context_server_registry,
Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
+ Some(model.clone()),
cx,
)
});
- subagent
- .update(cx, |thread, cx| thread.submit_user_message("Do work", cx))
- .unwrap();
- cx.run_until_parked();
+ let mut handles = Vec::new();
+ for _ in 0..MAX_PARALLEL_SUBAGENTS {
+ let handle = cx
+ .update(|cx| {
+ NativeThreadEnvironment::create_subagent_thread(
+ native_agent.downgrade(),
+ parent_thread.clone(),
+ "some title".to_string(),
+ "some task".to_string(),
+ None,
+ None,
+ cx,
+ )
+ })
+ .expect("Expected to be able to create subagent thread");
+ handles.push(handle);
+ }
- subagent.read_with(cx, |thread, _| {
- assert!(!thread.is_turn_complete(), "turn should be in progress");
+ let result = cx.update(|cx| {
+ NativeThreadEnvironment::create_subagent_thread(
+ native_agent.downgrade(),
+ parent_thread.clone(),
+ "some title".to_string(),
+ "some task".to_string(),
+ None,
+ None,
+ cx,
+ )
});
+ assert!(result.is_err());
+ assert_eq!(
+ result.err().unwrap().to_string(),
+ format!(
+ "Maximum parallel subagents ({}) reached. Wait for existing subagents to complete.",
+ MAX_PARALLEL_SUBAGENTS
+ )
+ );
+}
- fake_model.send_last_completion_stream_error(LanguageModelCompletionError::NoApiKey {
- provider: LanguageModelProviderName::from("Fake".to_string()),
- });
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
+#[gpui::test]
+async fn test_subagent_tool_returns_summary(cx: &mut TestAppContext) {
+ init_test(cx);
- subagent.read_with(cx, |thread, _| {
- assert!(
- thread.is_turn_complete(),
- "turn should be complete after non-retryable error"
- );
- });
-}
-
-#[gpui::test]
-async fn test_subagent_timeout_triggers_early_summary(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
+ always_allow_tools(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["subagents".to_string()]);
});
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize your work".to_string(),
- context_low_prompt: "Context low, stop and summarize".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
+ 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 project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
+ cx.update(LanguageModelRegistry::test);
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let native_agent = NativeAgent::new(
+ project.clone(),
+ thread_store,
+ Templates::new(),
+ None,
+ fs,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let parent_thread = cx.new(|cx| {
+ Thread::new(
project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
+ project_context,
+ context_server_registry,
Templates::new(),
- model.clone(),
- subagent_context.clone(),
- std::collections::BTreeMap::new(),
+ Some(model.clone()),
cx,
)
});
- subagent.update(cx, |thread, _| {
- thread.add_tool(EchoTool);
- });
-
- subagent
- .update(cx, |thread, cx| {
- thread.submit_user_message("Do some work", cx)
+ let subagent_handle = cx
+ .update(|cx| {
+ NativeThreadEnvironment::create_subagent_thread(
+ native_agent.downgrade(),
+ parent_thread.clone(),
+ "some title".to_string(),
+ "task prompt".to_string(),
+ Some(Duration::from_millis(10)),
+ None,
+ cx,
+ )
})
- .unwrap();
- cx.run_until_parked();
-
- fake_model.send_last_completion_stream_text_chunk("Working on it...");
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
+ .expect("Failed to create subagent");
- let interrupt_result = subagent.update(cx, |thread, cx| thread.interrupt_for_summary(cx));
- assert!(
- interrupt_result.is_ok(),
- "interrupt_for_summary should succeed"
- );
+ let summary_task =
+ subagent_handle.wait_for_summary("summary prompt".to_string(), &cx.to_async());
cx.run_until_parked();
- let pending = fake_model.pending_completions();
- assert!(
- !pending.is_empty(),
- "should have pending completion for interrupted summary"
- );
-
- let messages = &pending.last().unwrap().messages;
- let user_messages: Vec<_> = messages
- .iter()
- .filter(|m| m.role == language_model::Role::User)
- .collect();
-
- let last_user = user_messages.last().unwrap();
- let content_str = last_user.content[0].to_str().unwrap();
- assert!(
- content_str.contains("Context low") || content_str.contains("stop and summarize"),
- "context_low_prompt should be sent when interrupting: got {:?}",
- content_str
- );
-}
-
-#[gpui::test]
-async fn test_context_low_check_returns_true_when_usage_high(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+ {
+ let messages = model.pending_completions().last().unwrap().messages.clone();
+ // Ensure that model received a system prompt
+ assert_eq!(messages[0].role, Role::System);
+ // Ensure that model received a task prompt
+ assert_eq!(messages[1].role, Role::User);
+ assert_eq!(
+ messages[1].content,
+ vec![MessageContent::Text("task prompt".to_string())]
+ );
+ }
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context,
- context_server_registry,
- Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
+ model.send_last_completion_stream_text_chunk("Some task response...");
+ model.end_last_completion_stream();
- subagent
- .update(cx, |thread, cx| thread.submit_user_message("Do work", cx))
- .unwrap();
cx.run_until_parked();
- let max_tokens = model.max_token_count();
- let high_usage = language_model::TokenUsage {
- input_tokens: (max_tokens as f64 * 0.80) as u64,
- output_tokens: 0,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- };
-
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(high_usage));
- fake_model.send_last_completion_stream_text_chunk("Working...");
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
- fake_model.end_last_completion_stream();
- cx.run_until_parked();
+ {
+ let messages = model.pending_completions().last().unwrap().messages.clone();
+ assert_eq!(messages[2].role, Role::Assistant);
+ assert_eq!(
+ messages[2].content,
+ vec![MessageContent::Text("Some task response...".to_string())]
+ );
+ // Ensure that model received a summary prompt
+ assert_eq!(messages[3].role, Role::User);
+ assert_eq!(
+ messages[3].content,
+ vec![MessageContent::Text("summary prompt".to_string())]
+ );
+ }
- let usage = subagent.read_with(cx, |thread, _| thread.latest_token_usage());
- assert!(usage.is_some(), "should have token usage after completion");
+ model.send_last_completion_stream_text_chunk("Some summary...");
+ model.end_last_completion_stream();
- let usage = usage.unwrap();
- let remaining_ratio = 1.0 - (usage.used_tokens as f32 / usage.max_tokens as f32);
- assert!(
- remaining_ratio <= 0.25,
- "remaining ratio should be at or below 25% (got {}%), indicating context is low",
- remaining_ratio * 100.0
- );
+ let result = summary_task.await;
+ assert_eq!(result.unwrap(), "Some summary...\n");
}
#[gpui::test]
-async fn test_allowed_tools_rejects_unknown_tool(cx: &mut TestAppContext) {
+async fn test_subagent_tool_includes_cancellation_notice_when_timeout_is_exceeded(
+ cx: &mut TestAppContext,
+) {
init_test(cx);
+ always_allow_tools(cx);
+
cx.update(|cx| {
cx.update_flags(true, vec!["subagents".to_string()]);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({})).await;
- let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
let project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+ cx.update(LanguageModelRegistry::test);
let model = Arc::new(FakeLanguageModel::default());
-
- let parent = cx.new(|cx| {
- let mut thread = Thread::new(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- Templates::new(),
- Some(model.clone()),
- cx,
- );
- thread.add_tool(EchoTool);
- thread
- });
-
- #[allow(clippy::arc_with_non_send_sync)]
- let tool = Arc::new(SubagentTool::new(parent.downgrade(), 0));
-
- let allowed_tools = Some(vec!["nonexistent_tool".to_string()]);
- let result = cx.read(|cx| tool.validate_allowed_tools(&allowed_tools, cx));
-
- assert!(result.is_err(), "should reject unknown tool");
- let err_msg = result.unwrap_err().to_string();
- assert!(
- err_msg.contains("nonexistent_tool"),
- "error should mention the invalid tool name: {}",
- err_msg
- );
- assert!(
- err_msg.contains("do not exist"),
- "error should explain the tool does not exist: {}",
- err_msg
- );
-}
-
-#[gpui::test]
-async fn test_subagent_empty_response_handled(cx: &mut TestAppContext) {
- let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
- let fake_model = model.as_fake();
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let subagent_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("parent-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-id"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let project = thread.read_with(cx, |t, _| t.project.clone());
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let subagent = cx.new(|cx| {
- Thread::new_subagent(
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let native_agent = NativeAgent::new(
+ project.clone(),
+ thread_store,
+ Templates::new(),
+ None,
+ fs,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let parent_thread = cx.new(|cx| {
+ Thread::new(
project.clone(),
project_context,
context_server_registry,
Templates::new(),
- model.clone(),
- subagent_context,
- std::collections::BTreeMap::new(),
+ Some(model.clone()),
cx,
)
});
- subagent
- .update(cx, |thread, cx| thread.submit_user_message("Do work", cx))
- .unwrap();
- cx.run_until_parked();
+ let subagent_handle = cx
+ .update(|cx| {
+ NativeThreadEnvironment::create_subagent_thread(
+ native_agent.downgrade(),
+ parent_thread.clone(),
+ "some title".to_string(),
+ "task prompt".to_string(),
+ Some(Duration::from_millis(100)),
+ None,
+ cx,
+ )
+ })
+ .expect("Failed to create subagent");
+
+ let summary_task =
+ subagent_handle.wait_for_summary("summary prompt".to_string(), &cx.to_async());
- fake_model
- .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
- fake_model.end_last_completion_stream();
cx.run_until_parked();
- subagent.read_with(cx, |thread, _| {
- assert!(
- thread.is_turn_complete(),
- "turn should complete even with empty response"
+ {
+ let messages = model.pending_completions().last().unwrap().messages.clone();
+ // Ensure that model received a system prompt
+ assert_eq!(messages[0].role, Role::System);
+ // Ensure that model received a task prompt
+ assert_eq!(
+ messages[1].content,
+ vec![MessageContent::Text("task prompt".to_string())]
);
- });
-}
-
-#[gpui::test]
-async fn test_nested_subagent_at_depth_2_succeeds(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.update(|cx| {
- cx.update_flags(true, vec!["subagents".to_string()]);
- });
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(path!("/test"), json!({})).await;
- let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
- let project_context = cx.new(|_cx| ProjectContext::default());
- let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
- let model = Arc::new(FakeLanguageModel::default());
-
- let depth_1_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("root-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-1"),
- depth: 1,
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- };
-
- let depth_1_subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- Templates::new(),
- model.clone(),
- depth_1_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
-
- depth_1_subagent.read_with(cx, |thread, _| {
- assert_eq!(thread.depth(), 1);
- assert!(thread.is_subagent());
- });
-
- let depth_2_context = SubagentContext {
- parent_thread_id: agent_client_protocol::SessionId::new("depth-1-id"),
- tool_use_id: language_model::LanguageModelToolUseId::from("tool-use-2"),
- depth: 2,
- summary_prompt: "Summarize depth 2".to_string(),
- context_low_prompt: "Context low depth 2".to_string(),
- };
+ }
- let depth_2_subagent = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- Templates::new(),
- model.clone(),
- depth_2_context,
- std::collections::BTreeMap::new(),
- cx,
- )
- });
+ // Don't complete the initial model stream β let the timeout expire instead.
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.run_until_parked();
- depth_2_subagent.read_with(cx, |thread, _| {
- assert_eq!(thread.depth(), 2);
- assert!(thread.is_subagent());
- });
+ // After the timeout fires, the thread is cancelled and context_low_prompt is sent
+ // instead of the summary_prompt.
+ {
+ let messages = model.pending_completions().last().unwrap().messages.clone();
+ let last_user_message = messages
+ .iter()
+ .rev()
+ .find(|m| m.role == Role::User)
+ .unwrap();
+ assert_eq!(
+ last_user_message.content,
+ vec![MessageContent::Text("The time to complete the task was exceeded. Stop with the task and follow the directions below:\nsummary prompt".to_string())]
+ );
+ }
- depth_2_subagent
- .update(cx, |thread, cx| {
- thread.submit_user_message("Nested task", cx)
- })
- .unwrap();
- cx.run_until_parked();
+ model.send_last_completion_stream_text_chunk("Some context low response...");
+ model.end_last_completion_stream();
- let pending = model.as_fake().pending_completions();
- assert!(
- !pending.is_empty(),
- "depth-2 subagent should be able to submit messages"
- );
+ let result = summary_task.await;
+ assert_eq!(result.unwrap(), "Some context low response...\n");
}
#[gpui::test]
-async fn test_subagent_uses_tool_and_returns_result(cx: &mut TestAppContext) {
+async fn test_subagent_inherits_parent_thread_tools(cx: &mut TestAppContext) {
init_test(cx);
+
always_allow_tools(cx);
cx.update(|cx| {
@@ -63,22 +63,13 @@ pub const MAX_SUBAGENT_DEPTH: u8 = 4;
pub const MAX_PARALLEL_SUBAGENTS: usize = 8;
/// Context passed to a subagent thread for lifecycle management
-#[derive(Clone)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubagentContext {
/// ID of the parent thread
pub parent_thread_id: acp::SessionId,
- /// ID of the tool call that spawned this subagent
- pub tool_use_id: LanguageModelToolUseId,
-
/// Current depth level (0 = root agent, 1 = first-level subagent, etc.)
pub depth: u8,
-
- /// Prompt to send when subagent completes successfully
- pub summary_prompt: String,
-
- /// Prompt to send when context is running low (β€25% remaining)
- pub context_low_prompt: String,
}
/// The ID of the user prompt that initiated a request.
@@ -179,7 +170,7 @@ pub enum UserMessageContent {
impl UserMessage {
pub fn to_markdown(&self) -> String {
- let mut markdown = String::from("## User\n\n");
+ let mut markdown = String::new();
for content in &self.content {
match content {
@@ -431,7 +422,7 @@ fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive<u32>>) ->
impl AgentMessage {
pub fn to_markdown(&self) -> String {
- let mut markdown = String::from("## Assistant\n\n");
+ let mut markdown = String::new();
for content in &self.content {
match content {
@@ -587,6 +578,11 @@ pub trait TerminalHandle {
fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result<bool>;
}
+pub trait SubagentHandle {
+ fn id(&self) -> acp::SessionId;
+ fn wait_for_summary(&self, summary_prompt: String, cx: &AsyncApp) -> Task<Result<String>>;
+}
+
pub trait ThreadEnvironment {
fn create_terminal(
&self,
@@ -595,6 +591,16 @@ pub trait ThreadEnvironment {
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>>;
+
+ fn create_subagent(
+ &self,
+ parent_thread: Entity<Thread>,
+ label: String,
+ initial_prompt: String,
+ timeout: Option<Duration>,
+ allowed_tools: Option<Vec<String>>,
+ cx: &mut App,
+ ) -> Result<Rc<dyn SubagentHandle>>;
}
#[derive(Debug)]
@@ -605,6 +611,7 @@ pub enum ThreadEvent {
ToolCall(acp::ToolCall),
ToolCallUpdate(acp_thread::ToolCallUpdate),
ToolCallAuthorization(ToolCallAuthorization),
+ SubagentSpawned(acp::SessionId),
Retry(acp_thread::RetryStatus),
Stop(acp::StopReason),
}
@@ -827,6 +834,27 @@ impl Thread {
.embedded_context(true)
}
+ pub fn new_subagent(parent_thread: &Entity<Thread>, cx: &mut Context<Self>) -> Self {
+ let project = parent_thread.read(cx).project.clone();
+ let project_context = parent_thread.read(cx).project_context.clone();
+ let context_server_registry = parent_thread.read(cx).context_server_registry.clone();
+ let templates = parent_thread.read(cx).templates.clone();
+ let model = parent_thread.read(cx).model().cloned();
+ let mut thread = Self::new(
+ project,
+ project_context,
+ context_server_registry,
+ templates,
+ model,
+ cx,
+ );
+ thread.subagent_context = Some(SubagentContext {
+ parent_thread_id: parent_thread.read(cx).id().clone(),
+ depth: parent_thread.read(cx).depth() + 1,
+ });
+ thread
+ }
+
pub fn new(
project: Entity<Project>,
project_context: Entity<ProjectContext>,
@@ -889,78 +917,6 @@ impl Thread {
}
}
- pub fn new_subagent(
- project: Entity<Project>,
- project_context: Entity<ProjectContext>,
- context_server_registry: Entity<ContextServerRegistry>,
- templates: Arc<Templates>,
- model: Arc<dyn LanguageModel>,
- subagent_context: SubagentContext,
- parent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
- cx: &mut Context<Self>,
- ) -> Self {
- let settings = AgentSettings::get_global(cx);
- let profile_id = settings.default_profile.clone();
- let enable_thinking = settings
- .default_model
- .as_ref()
- .is_some_and(|model| model.enable_thinking);
- let thinking_effort = settings
- .default_model
- .as_ref()
- .and_then(|model| model.effort.clone());
- let action_log = cx.new(|_cx| ActionLog::new(project.clone()));
- let (prompt_capabilities_tx, prompt_capabilities_rx) =
- watch::channel(Self::prompt_capabilities(Some(model.as_ref())));
-
- // Rebind tools that hold thread references to use this subagent's thread
- // instead of the parent's thread. This is critical for tools like EditFileTool
- // that make model requests using the thread's ID.
- let weak_self = cx.weak_entity();
- let tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>> = parent_tools
- .into_iter()
- .map(|(name, tool)| {
- let rebound = tool.rebind_thread(weak_self.clone()).unwrap_or(tool);
- (name, rebound)
- })
- .collect();
-
- Self {
- id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()),
- prompt_id: PromptId::new(),
- updated_at: Utc::now(),
- title: None,
- pending_title_generation: None,
- pending_summary_generation: None,
- summary: None,
- messages: Vec::new(),
- user_store: project.read(cx).user_store(),
- running_turn: None,
- has_queued_message: false,
- pending_message: None,
- tools,
- request_token_usage: HashMap::default(),
- cumulative_token_usage: TokenUsage::default(),
- initial_project_snapshot: Task::ready(None).shared(),
- context_server_registry,
- profile_id,
- project_context,
- templates,
- model: Some(model),
- summarization_model: None,
- thinking_enabled: enable_thinking,
- thinking_effort,
- prompt_capabilities_tx,
- prompt_capabilities_rx,
- project,
- action_log,
- file_read_times: HashMap::default(),
- imported: false,
- subagent_context: Some(subagent_context),
- running_subagents: Vec::new(),
- }
- }
-
pub fn id(&self) -> &acp::SessionId {
&self.id
}
@@ -1077,6 +1033,7 @@ impl Thread {
}),
)
.raw_output(output),
+ None,
);
}
@@ -1167,7 +1124,7 @@ impl Thread {
prompt_capabilities_rx,
file_read_times: HashMap::default(),
imported: db_thread.imported,
- subagent_context: None,
+ subagent_context: db_thread.subagent_context,
running_subagents: Vec::new(),
}
}
@@ -1188,6 +1145,7 @@ impl Thread {
}),
profile: Some(self.profile_id.clone()),
imported: self.imported,
+ subagent_context: self.subagent_context.clone(),
};
cx.background_spawn(async move {
@@ -1286,53 +1244,106 @@ impl Thread {
pub fn add_default_tools(
&mut self,
+ allowed_tool_names: Option<Vec<&str>>,
environment: Rc<dyn ThreadEnvironment>,
cx: &mut Context<Self>,
) {
let language_registry = self.project.read(cx).languages().clone();
- self.add_tool(CopyPathTool::new(self.project.clone()));
- self.add_tool(CreateDirectoryTool::new(self.project.clone()));
- self.add_tool(DeletePathTool::new(
- self.project.clone(),
- self.action_log.clone(),
- ));
- self.add_tool(DiagnosticsTool::new(self.project.clone()));
- self.add_tool(EditFileTool::new(
- self.project.clone(),
- cx.weak_entity(),
- language_registry.clone(),
- Templates::new(),
- ));
- self.add_tool(StreamingEditFileTool::new(
- self.project.clone(),
- cx.weak_entity(),
- language_registry,
- Templates::new(),
- ));
- self.add_tool(FetchTool::new(self.project.read(cx).client().http_client()));
- self.add_tool(FindPathTool::new(self.project.clone()));
- self.add_tool(GrepTool::new(self.project.clone()));
- self.add_tool(ListDirectoryTool::new(self.project.clone()));
- self.add_tool(MovePathTool::new(self.project.clone()));
- self.add_tool(NowTool);
- self.add_tool(OpenTool::new(self.project.clone()));
- self.add_tool(ReadFileTool::new(
- cx.weak_entity(),
- self.project.clone(),
- self.action_log.clone(),
- ));
- self.add_tool(SaveFileTool::new(self.project.clone()));
- self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
- self.add_tool(TerminalTool::new(self.project.clone(), environment));
- self.add_tool(ThinkingTool);
- self.add_tool(WebSearchTool);
+ self.add_tool(
+ CopyPathTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ CreateDirectoryTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ DeletePathTool::new(self.project.clone(), self.action_log.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ DiagnosticsTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ EditFileTool::new(
+ self.project.clone(),
+ cx.weak_entity(),
+ language_registry.clone(),
+ Templates::new(),
+ ),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ StreamingEditFileTool::new(
+ self.project.clone(),
+ cx.weak_entity(),
+ language_registry,
+ Templates::new(),
+ ),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ FetchTool::new(self.project.read(cx).client().http_client()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ FindPathTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ GrepTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ ListDirectoryTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ MovePathTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(NowTool, allowed_tool_names.as_ref());
+ self.add_tool(
+ OpenTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ ReadFileTool::new(
+ cx.weak_entity(),
+ self.project.clone(),
+ self.action_log.clone(),
+ ),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ SaveFileTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ RestoreFileFromDiskTool::new(self.project.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(
+ TerminalTool::new(self.project.clone(), environment.clone()),
+ allowed_tool_names.as_ref(),
+ );
+ self.add_tool(ThinkingTool, allowed_tool_names.as_ref());
+ self.add_tool(WebSearchTool, allowed_tool_names.as_ref());
if cx.has_flag::<SubagentsFeatureFlag>() && self.depth() < MAX_SUBAGENT_DEPTH {
- self.add_tool(SubagentTool::new(cx.weak_entity(), self.depth()));
+ self.add_tool(
+ SubagentTool::new(cx.weak_entity(), environment),
+ allowed_tool_names.as_ref(),
+ );
}
}
- pub fn add_tool<T: AgentTool>(&mut self, tool: T) {
+ pub fn add_tool<T: AgentTool>(&mut self, tool: T, allowed_tool_names: Option<&Vec<&str>>) {
+ if allowed_tool_names.is_some_and(|tool_names| !tool_names.contains(&T::NAME)) {
+ return;
+ }
+
debug_assert!(
!self.tools.contains_key(T::NAME),
"Duplicate tool name: {}",
@@ -1345,10 +1356,6 @@ impl Thread {
self.tools.remove(name).is_some()
}
- pub fn restrict_tools(&mut self, allowed: &collections::HashSet<SharedString>) {
- self.tools.retain(|name, _| allowed.contains(name));
- }
-
pub fn profile(&self) -> &AgentProfileId {
&self.profile_id
}
@@ -1778,6 +1785,7 @@ impl Thread {
acp::ToolCallStatus::Completed
})
.raw_output(tool_result.output.clone()),
+ None,
);
this.update(cx, |this, _cx| {
this.pending_message()
@@ -2048,6 +2056,7 @@ impl Thread {
.title(title.as_str())
.kind(kind)
.raw_input(tool_use.input.clone()),
+ None,
);
}
@@ -2472,13 +2481,19 @@ impl Thread {
self.tools.keys().cloned().collect()
}
- pub fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
+ pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
self.running_subagents.push(subagent);
}
- pub fn unregister_running_subagent(&mut self, subagent: &WeakEntity<Thread>) {
- self.running_subagents
- .retain(|s| s.entity_id() != subagent.entity_id());
+ pub(crate) fn unregister_running_subagent(
+ &mut self,
+ subagent_session_id: &acp::SessionId,
+ cx: &App,
+ ) {
+ self.running_subagents.retain(|s| {
+ s.upgrade()
+ .map_or(false, |s| s.read(cx).id() != subagent_session_id)
+ });
}
pub fn running_subagent_count(&self) -> usize {
@@ -2492,51 +2507,23 @@ impl Thread {
self.subagent_context.is_some()
}
- pub fn depth(&self) -> u8 {
- self.subagent_context.as_ref().map(|c| c.depth).unwrap_or(0)
- }
-
- pub fn is_turn_complete(&self) -> bool {
- self.running_turn.is_none()
+ pub fn parent_thread_id(&self) -> Option<acp::SessionId> {
+ self.subagent_context
+ .as_ref()
+ .map(|c| c.parent_thread_id.clone())
}
- pub fn submit_user_message(
- &mut self,
- content: impl Into<String>,
- cx: &mut Context<Self>,
- ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
- let content = content.into();
- self.messages.push(Message::User(UserMessage {
- id: UserMessageId::new(),
- content: vec![UserMessageContent::Text(content)],
- }));
- cx.notify();
- self.send_existing(cx)
+ pub fn depth(&self) -> u8 {
+ self.subagent_context.as_ref().map(|c| c.depth).unwrap_or(0)
}
- pub fn interrupt_for_summary(
- &mut self,
- cx: &mut Context<Self>,
- ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
- let context = self
- .subagent_context
- .as_ref()
- .context("Not a subagent thread")?;
- let prompt = context.context_low_prompt.clone();
- self.cancel(cx).detach();
- self.submit_user_message(prompt, cx)
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_subagent_context(&mut self, context: SubagentContext) {
+ self.subagent_context = Some(context);
}
- pub fn request_final_summary(
- &mut self,
- cx: &mut Context<Self>,
- ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
- let context = self
- .subagent_context
- .as_ref()
- .context("Not a subagent thread")?;
- let prompt = context.summary_prompt.clone();
- self.submit_user_message(prompt, cx)
+ pub fn is_turn_complete(&self) -> bool {
+ self.running_turn.is_none()
}
fn build_request_messages(
@@ -2584,11 +2571,16 @@ impl Thread {
if ix > 0 {
markdown.push('\n');
}
+ match message {
+ Message::User(_) => markdown.push_str("## User\n\n"),
+ Message::Agent(_) => markdown.push_str("## Assistant\n\n"),
+ Message::Resume => {}
+ }
markdown.push_str(&message.to_markdown());
}
if let Some(message) = self.pending_message.as_ref() {
- markdown.push('\n');
+ markdown.push_str("\n## Assistant\n\n");
markdown.push_str(&message.to_markdown());
}
@@ -2795,15 +2787,6 @@ where
fn erase(self) -> Arc<dyn AnyAgentTool> {
Arc::new(Erased(Arc::new(self)))
}
-
- /// Create a new instance of this tool bound to a different thread.
- /// This is used when creating subagents, so that tools like EditFileTool
- /// that hold a thread reference will use the subagent's thread instead
- /// of the parent's thread.
- /// Returns None if the tool doesn't need rebinding (most tools).
- fn rebind_thread(&self, _new_thread: WeakEntity<Thread>) -> Option<Arc<dyn AnyAgentTool>> {
- None
- }
}
pub struct Erased<T>(T);
@@ -2835,14 +2818,6 @@ pub trait AnyAgentTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Result<()>;
- /// Create a new instance of this tool bound to a different thread.
- /// This is used when creating subagents, so that tools like EditFileTool
- /// that hold a thread reference will use the subagent's thread instead
- /// of the parent's thread.
- /// Returns None if the tool doesn't need rebinding (most tools).
- fn rebind_thread(&self, _new_thread: WeakEntity<Thread>) -> Option<Arc<dyn AnyAgentTool>> {
- None
- }
}
impl<T> AnyAgentTool for Erased<Arc<T>>
@@ -2906,10 +2881,6 @@ where
let output = serde_json::from_value(output)?;
self.0.replay(input, output, event_stream, cx)
}
-
- fn rebind_thread(&self, new_thread: WeakEntity<Thread>) -> Option<Arc<dyn AnyAgentTool>> {
- self.0.rebind_thread(new_thread)
- }
}
#[derive(Clone)]
@@ -2970,10 +2941,13 @@ impl ThreadEventStream {
&self,
tool_use_id: &LanguageModelToolUseId,
fields: acp::ToolCallUpdateFields,
+ meta: Option<acp::Meta>,
) {
self.0
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
- acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(),
+ acp::ToolCallUpdate::new(tool_use_id.to_string(), fields)
+ .meta(meta)
+ .into(),
)))
.ok();
}
@@ -3081,7 +3055,16 @@ impl ToolCallEventStream {
pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) {
self.stream
- .update_tool_call_fields(&self.tool_use_id, fields);
+ .update_tool_call_fields(&self.tool_use_id, fields, None);
+ }
+
+ pub fn update_fields_with_meta(
+ &self,
+ fields: acp::ToolCallUpdateFields,
+ meta: Option<acp::Meta>,
+ ) {
+ self.stream
+ .update_tool_call_fields(&self.tool_use_id, fields, meta);
}
pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
@@ -3097,16 +3080,10 @@ impl ToolCallEventStream {
.ok();
}
- pub fn update_subagent_thread(&self, thread: Entity<acp_thread::AcpThread>) {
+ pub fn subagent_spawned(&self, id: acp::SessionId) {
self.stream
.0
- .unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
- acp_thread::ToolCallUpdateSubagentThread {
- id: acp::ToolCallId::new(self.tool_use_id.to_string()),
- thread,
- }
- .into(),
- )))
+ .unbounded_send(Ok(ThreadEvent::SubagentSpawned(id)))
.ok();
}
@@ -3421,6 +3398,12 @@ impl From<&str> for UserMessageContent {
}
}
+impl From<String> for UserMessageContent {
+ fn from(text: String) -> Self {
+ Self::Text(text)
+ }
+}
+
impl UserMessageContent {
pub fn from_content_block(value: acp::ContentBlock, path_style: PathStyle) -> Self {
match value {
@@ -114,7 +114,12 @@ impl ThreadStore {
let database_connection = ThreadsDatabase::connect(cx);
cx.spawn(async move |this, cx| {
let database = database_connection.await.map_err(|err| anyhow!(err))?;
- let threads = database.list_threads().await?;
+ let threads = database
+ .list_threads()
+ .await?
+ .into_iter()
+ .filter(|thread| thread.parent_session_id.is_none())
+ .collect::<Vec<_>>();
this.update(cx, |this, cx| {
this.threads = threads;
cx.notify();
@@ -156,6 +161,7 @@ mod tests {
model: None,
profile: None,
imported: false,
+ subagent_context: None,
}
}
@@ -146,15 +146,6 @@ impl EditFileTool {
}
}
- pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
- Self {
- project: self.project.clone(),
- thread: new_thread,
- language_registry: self.language_registry.clone(),
- templates: self.templates.clone(),
- }
- }
-
fn authorize(
&self,
input: &EditFileToolInput,
@@ -665,13 +656,6 @@ impl AgentTool for EditFileTool {
}));
Ok(())
}
-
- fn rebind_thread(
- &self,
- new_thread: gpui::WeakEntity<crate::Thread>,
- ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
- Some(self.with_thread(new_thread).erase())
- }
}
/// Validate that the file path is valid, meaning:
@@ -65,14 +65,6 @@ impl ReadFileTool {
action_log,
}
}
-
- pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
- Self {
- thread: new_thread,
- project: self.project.clone(),
- action_log: self.action_log.clone(),
- }
- }
}
impl AgentTool for ReadFileTool {
@@ -314,13 +306,6 @@ impl AgentTool for ReadFileTool {
result
})
}
-
- fn rebind_thread(
- &self,
- new_thread: WeakEntity<Thread>,
- ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
- Some(self.with_thread(new_thread).erase())
- }
}
#[cfg(test)]
@@ -1,31 +1,15 @@
-use acp_thread::{AcpThread, AgentConnection, UserMessageId};
-use action_log::ActionLog;
+use acp_thread::SUBAGENT_SESSION_ID_META_KEY;
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
-use collections::{BTreeMap, HashSet};
-use futures::{FutureExt, channel::mpsc};
-use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
-use language_model::LanguageModelToolUseId;
-use project::Project;
+use futures::FutureExt as _;
+use gpui::{App, Entity, SharedString, Task, WeakEntity};
+use language_model::LanguageModelToolResultContent;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use smol::stream::StreamExt;
-use std::any::Any;
-use std::path::Path;
-use std::rc::Rc;
use std::sync::Arc;
-use std::time::Duration;
-use util::ResultExt;
-use watch;
+use std::{rc::Rc, time::Duration};
-use crate::{
- AgentTool, AnyAgentTool, MAX_PARALLEL_SUBAGENTS, MAX_SUBAGENT_DEPTH, SubagentContext, Thread,
- ThreadEvent, ToolCallAuthorization, ToolCallEventStream,
-};
-
-/// When a subagent's remaining context window falls below this fraction (25%),
-/// the "context running out" prompt is sent to encourage the subagent to wrap up.
-const CONTEXT_LOW_THRESHOLD: f32 = 0.25;
+use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream};
/// Spawns a subagent with its own context window to perform a delegated task.
///
@@ -64,13 +48,6 @@ pub struct SubagentToolInput {
/// Example: "Summarize what you found, listing the top 3 alternatives with pros/cons."
pub summary_prompt: String,
- /// The prompt sent if the subagent is running low on context (25% remaining).
- /// Should instruct it to stop and summarize progress so far, plus what's left undone.
- ///
- /// Example: "Context is running low. Stop and summarize your progress so far,
- /// and list what remains to be investigated."
- pub context_low_prompt: String,
-
/// Optional: Maximum runtime in milliseconds. If exceeded, the subagent is
/// asked to summarize and return. No timeout by default.
#[serde(default)]
@@ -83,36 +60,47 @@ pub struct SubagentToolInput {
pub allowed_tools: Option<Vec<String>>,
}
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct SubagentToolOutput {
+ pub subagent_session_id: acp::SessionId,
+ pub summary: String,
+}
+
+impl From<SubagentToolOutput> for LanguageModelToolResultContent {
+ fn from(output: SubagentToolOutput) -> Self {
+ output.summary.into()
+ }
+}
+
/// Tool that spawns a subagent thread to work on a task.
pub struct SubagentTool {
parent_thread: WeakEntity<Thread>,
- current_depth: u8,
+ environment: Rc<dyn ThreadEnvironment>,
}
impl SubagentTool {
- pub fn new(parent_thread: WeakEntity<Thread>, current_depth: u8) -> Self {
+ pub fn new(parent_thread: WeakEntity<Thread>, environment: Rc<dyn ThreadEnvironment>) -> Self {
Self {
parent_thread,
- current_depth,
+ environment,
}
}
- pub fn validate_allowed_tools(
- &self,
+ fn validate_allowed_tools(
allowed_tools: &Option<Vec<String>>,
+ parent_thread: &Entity<Thread>,
cx: &App,
) -> Result<()> {
let Some(allowed_tools) = allowed_tools else {
return Ok(());
};
- let invalid_tools: Vec<_> = self.parent_thread.read_with(cx, |thread, _cx| {
- allowed_tools
- .iter()
- .filter(|tool| !thread.tools.contains_key(tool.as_str()))
- .map(|s| format!("'{s}'"))
- .collect()
- })?;
+ let thread = parent_thread.read(cx);
+ let invalid_tools: Vec<_> = allowed_tools
+ .iter()
+ .filter(|tool| !thread.tools.contains_key(tool.as_str()))
+ .map(|s| format!("'{s}'"))
+ .collect::<Vec<_>>();
if !invalid_tools.is_empty() {
return Err(anyhow!(
@@ -127,9 +115,9 @@ impl SubagentTool {
impl AgentTool for SubagentTool {
type Input = SubagentToolInput;
- type Output = String;
+ type Output = SubagentToolOutput;
- const NAME: &'static str = acp_thread::SUBAGENT_TOOL_NAME;
+ const NAME: &'static str = "subagent";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
@@ -150,428 +138,156 @@ impl AgentTool for SubagentTool {
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
- ) -> Task<Result<String>> {
- if self.current_depth >= MAX_SUBAGENT_DEPTH {
- return Task::ready(Err(anyhow!(
- "Maximum subagent depth ({}) reached",
- MAX_SUBAGENT_DEPTH
- )));
- }
+ ) -> Task<Result<SubagentToolOutput>> {
+ let Some(parent_thread_entity) = self.parent_thread.upgrade() else {
+ return Task::ready(Err(anyhow!("Parent thread no longer exists")));
+ };
- if let Err(e) = self.validate_allowed_tools(&input.allowed_tools, cx) {
+ if let Err(e) =
+ Self::validate_allowed_tools(&input.allowed_tools, &parent_thread_entity, cx)
+ {
return Task::ready(Err(e));
}
- let Some(parent_thread_entity) = self.parent_thread.upgrade() else {
- return Task::ready(Err(anyhow!(
- "Parent thread no longer exists (subagent depth={})",
- self.current_depth + 1
- )));
+ let subagent = match self.environment.create_subagent(
+ parent_thread_entity,
+ input.label,
+ input.task_prompt,
+ input.timeout_ms.map(|ms| Duration::from_millis(ms)),
+ input.allowed_tools,
+ cx,
+ ) {
+ Ok(subagent) => subagent,
+ Err(err) => return Task::ready(Err(err)),
};
- let parent_thread = parent_thread_entity.read(cx);
-
- let running_count = parent_thread.running_subagent_count();
- if running_count >= MAX_PARALLEL_SUBAGENTS {
- return Task::ready(Err(anyhow!(
- "Maximum parallel subagents ({}) reached. Wait for existing subagents to complete.",
- MAX_PARALLEL_SUBAGENTS
- )));
- }
- let parent_model = parent_thread.model().cloned();
- let Some(model) = parent_model else {
- return Task::ready(Err(anyhow!("No model configured")));
- };
+ let subagent_session_id = subagent.id();
- let parent_thread_id = parent_thread.id().clone();
- let project = parent_thread.project.clone();
- let project_context = parent_thread.project_context().clone();
- let context_server_registry = parent_thread.context_server_registry.clone();
- let templates = parent_thread.templates.clone();
- let parent_tools = parent_thread.tools.clone();
- let current_depth = self.current_depth;
- let parent_thread_weak = self.parent_thread.clone();
+ event_stream.subagent_spawned(subagent_session_id.clone());
+ let meta = acp::Meta::from_iter([(
+ SUBAGENT_SESSION_ID_META_KEY.into(),
+ subagent_session_id.to_string().into(),
+ )]);
+ event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
cx.spawn(async move |cx| {
- let subagent_context = SubagentContext {
- parent_thread_id: parent_thread_id.clone(),
- tool_use_id: LanguageModelToolUseId::from(uuid::Uuid::new_v4().to_string()),
- depth: current_depth + 1,
- summary_prompt: input.summary_prompt.clone(),
- context_low_prompt: input.context_low_prompt.clone(),
- };
-
- // Determine which tools this subagent gets
- let subagent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>> =
- if let Some(ref allowed) = input.allowed_tools {
- let allowed_set: HashSet<&str> = allowed.iter().map(|s| s.as_str()).collect();
- parent_tools
- .iter()
- .filter(|(name, _)| allowed_set.contains(name.as_ref()))
- .map(|(name, tool)| (name.clone(), tool.clone()))
- .collect()
- } else {
- parent_tools.clone()
- };
-
- let subagent_thread: Entity<Thread> = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- templates.clone(),
- model.clone(),
- subagent_context,
- subagent_tools,
- cx,
- )
- });
-
- let subagent_weak = subagent_thread.downgrade();
-
- let acp_thread: Entity<AcpThread> = cx.new(|cx| {
- let session_id = subagent_thread.read(cx).id().clone();
- let action_log: Entity<ActionLog> = cx.new(|_| ActionLog::new(project.clone()));
- let connection: Rc<dyn AgentConnection> = Rc::new(SubagentDisplayConnection);
- AcpThread::new(
- &input.label,
- connection,
- project.clone(),
- action_log,
- session_id,
- watch::Receiver::constant(acp::PromptCapabilities::new()),
- cx,
- )
- });
-
- event_stream.update_subagent_thread(acp_thread.clone());
-
- let mut user_stop_rx: watch::Receiver<bool> =
- acp_thread.update(cx, |thread, _| thread.user_stop_receiver());
-
- if let Some(parent) = parent_thread_weak.upgrade() {
- parent.update(cx, |thread, _cx| {
- thread.register_running_subagent(subagent_weak.clone());
- });
- }
+ let summary_task = subagent.wait_for_summary(input.summary_prompt, cx);
- // Helper to wait for user stop signal on the subagent card
- let wait_for_user_stop = async {
- loop {
- if *user_stop_rx.borrow() {
- return;
- }
- if user_stop_rx.changed().await.is_err() {
- std::future::pending::<()>().await;
- }
- }
- };
-
- // Run the subagent, handling cancellation from both:
- // 1. Parent turn cancellation (event_stream.cancelled_by_user)
- // 2. Direct user stop on subagent card (user_stop_rx)
- let result = futures::select! {
- result = run_subagent(
- &subagent_thread,
- &acp_thread,
- input.task_prompt,
- input.timeout_ms,
- cx,
- ).fuse() => result,
+ futures::select_biased! {
+ summary = summary_task.fuse() => summary.map(|summary| SubagentToolOutput {
+ summary,
+ subagent_session_id,
+ }),
_ = event_stream.cancelled_by_user().fuse() => {
- let _ = subagent_thread.update(cx, |thread, cx| {
- thread.cancel(cx).detach();
- });
- Err(anyhow!("Subagent cancelled by user"))
- }
- _ = wait_for_user_stop.fuse() => {
- let _ = subagent_thread.update(cx, |thread, cx| {
- thread.cancel(cx).detach();
- });
- Err(anyhow!("Subagent stopped by user"))
+ Err(anyhow!("Subagent was cancelled by user"))
}
- };
-
- if let Some(parent) = parent_thread_weak.upgrade() {
- let _ = parent.update(cx, |thread, _cx| {
- thread.unregister_running_subagent(&subagent_weak);
- });
}
-
- result
})
}
-}
-
-async fn run_subagent(
- subagent_thread: &Entity<Thread>,
- acp_thread: &Entity<AcpThread>,
- task_prompt: String,
- timeout_ms: Option<u64>,
- cx: &mut AsyncApp,
-) -> Result<String> {
- let mut events_rx =
- subagent_thread.update(cx, |thread, cx| thread.submit_user_message(task_prompt, cx))?;
-
- let acp_thread_weak = acp_thread.downgrade();
-
- let timed_out = if let Some(timeout) = timeout_ms {
- forward_events_with_timeout(
- &mut events_rx,
- &acp_thread_weak,
- Duration::from_millis(timeout),
- cx,
- )
- .await
- } else {
- forward_events_until_stop(&mut events_rx, &acp_thread_weak, cx).await;
- false
- };
-
- let should_interrupt =
- timed_out || check_context_low(subagent_thread, CONTEXT_LOW_THRESHOLD, cx);
-
- if should_interrupt {
- let mut summary_rx =
- subagent_thread.update(cx, |thread, cx| thread.interrupt_for_summary(cx))?;
- forward_events_until_stop(&mut summary_rx, &acp_thread_weak, cx).await;
- } else {
- let mut summary_rx =
- subagent_thread.update(cx, |thread, cx| thread.request_final_summary(cx))?;
- forward_events_until_stop(&mut summary_rx, &acp_thread_weak, cx).await;
- }
-
- Ok(extract_last_message(subagent_thread, cx))
-}
-async fn forward_events_until_stop(
- events_rx: &mut mpsc::UnboundedReceiver<Result<ThreadEvent>>,
- acp_thread: &WeakEntity<AcpThread>,
- cx: &mut AsyncApp,
-) {
- while let Some(event) = events_rx.next().await {
- match event {
- Ok(ThreadEvent::Stop(_)) => break,
- Ok(event) => {
- forward_event_to_acp_thread(event, acp_thread, cx);
- }
- Err(_) => break,
- }
- }
-}
-
-async fn forward_events_with_timeout(
- events_rx: &mut mpsc::UnboundedReceiver<Result<ThreadEvent>>,
- acp_thread: &WeakEntity<AcpThread>,
- timeout: Duration,
- cx: &mut AsyncApp,
-) -> bool {
- use futures::future::{self, Either};
-
- let deadline = std::time::Instant::now() + timeout;
-
- loop {
- let remaining = deadline.saturating_duration_since(std::time::Instant::now());
- if remaining.is_zero() {
- return true;
- }
-
- let timeout_future = cx.background_executor().timer(remaining);
- let event_future = events_rx.next();
-
- match future::select(event_future, timeout_future).await {
- Either::Left((event, _)) => match event {
- Some(Ok(ThreadEvent::Stop(_))) => return false,
- Some(Ok(event)) => {
- forward_event_to_acp_thread(event, acp_thread, cx);
- }
- Some(Err(_)) => return false,
- None => return false,
- },
- Either::Right((_, _)) => return true,
- }
- }
-}
-
-fn forward_event_to_acp_thread(
- event: ThreadEvent,
- acp_thread: &WeakEntity<AcpThread>,
- cx: &mut AsyncApp,
-) {
- match event {
- ThreadEvent::UserMessage(message) => {
- acp_thread
- .update(cx, |thread, cx| {
- for content in message.content {
- thread.push_user_content_block(
- Some(message.id.clone()),
- content.into(),
- cx,
- );
- }
- })
- .log_err();
- }
- ThreadEvent::AgentText(text) => {
- acp_thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(text.into(), false, cx)
- })
- .log_err();
- }
- ThreadEvent::AgentThinking(text) => {
- acp_thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(text.into(), true, cx)
- })
- .log_err();
- }
- ThreadEvent::ToolCallAuthorization(ToolCallAuthorization {
- tool_call,
- options,
- response,
- ..
- }) => {
- let outcome_task = acp_thread.update(cx, |thread, cx| {
- thread.request_tool_call_authorization(tool_call, options, true, cx)
- });
- if let Ok(Ok(task)) = outcome_task {
- cx.background_spawn(async move {
- if let acp::RequestPermissionOutcome::Selected(
- acp::SelectedPermissionOutcome { option_id, .. },
- ) = task.await
- {
- response.send(option_id).ok();
- }
- })
- .detach();
- }
- }
- ThreadEvent::ToolCall(tool_call) => {
- acp_thread
- .update(cx, |thread, cx| thread.upsert_tool_call(tool_call, cx))
- .log_err();
- }
- ThreadEvent::ToolCallUpdate(update) => {
- acp_thread
- .update(cx, |thread, cx| thread.update_tool_call(update, cx))
- .log_err();
- }
- ThreadEvent::Retry(status) => {
- acp_thread
- .update(cx, |thread, cx| thread.update_retry_status(status, cx))
- .log_err();
- }
- ThreadEvent::Stop(_) => {}
+ fn replay(
+ &self,
+ _input: Self::Input,
+ output: Self::Output,
+ event_stream: ToolCallEventStream,
+ _cx: &mut App,
+ ) -> Result<()> {
+ event_stream.subagent_spawned(output.subagent_session_id.clone());
+ let meta = acp::Meta::from_iter([(
+ SUBAGENT_SESSION_ID_META_KEY.into(),
+ output.subagent_session_id.to_string().into(),
+ )]);
+ event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
+ Ok(())
}
}
-fn check_context_low(thread: &Entity<Thread>, threshold: f32, cx: &mut AsyncApp) -> bool {
- thread.read_with(cx, |thread, _| {
- if let Some(usage) = thread.latest_token_usage() {
- let remaining_ratio = 1.0 - (usage.used_tokens as f32 / usage.max_tokens as f32);
- remaining_ratio <= threshold
- } else {
- false
- }
- })
-}
-
-fn extract_last_message(thread: &Entity<Thread>, cx: &mut AsyncApp) -> String {
- thread.read_with(cx, |thread, _| {
- thread
- .last_message()
- .map(|m| m.to_markdown())
- .unwrap_or_else(|| "No response from subagent".to_string())
- })
-}
-
#[cfg(test)]
mod tests {
use super::*;
- use language_model::LanguageModelToolSchemaFormat;
-
- #[test]
- fn test_subagent_tool_input_json_schema_is_valid() {
- let schema = SubagentTool::input_schema(LanguageModelToolSchemaFormat::JsonSchema);
- let schema_json = serde_json::to_value(&schema).expect("schema should serialize to JSON");
-
- assert!(
- schema_json.get("properties").is_some(),
- "schema should have properties"
- );
- let properties = schema_json.get("properties").unwrap();
-
- assert!(properties.get("label").is_some(), "should have label field");
- assert!(
- properties.get("task_prompt").is_some(),
- "should have task_prompt field"
- );
- assert!(
- properties.get("summary_prompt").is_some(),
- "should have summary_prompt field"
- );
- assert!(
- properties.get("context_low_prompt").is_some(),
- "should have context_low_prompt field"
- );
- assert!(
- properties.get("timeout_ms").is_some(),
- "should have timeout_ms field"
- );
- assert!(
- properties.get("allowed_tools").is_some(),
- "should have allowed_tools field"
- );
- }
-
- #[test]
- fn test_subagent_tool_name() {
- assert_eq!(SubagentTool::NAME, "subagent");
- }
-
- #[test]
- fn test_subagent_tool_kind() {
- assert_eq!(SubagentTool::kind(), acp::ToolKind::Other);
- }
-}
-
-struct SubagentDisplayConnection;
-
-impl AgentConnection for SubagentDisplayConnection {
- fn telemetry_id(&self) -> SharedString {
- acp_thread::SUBAGENT_TOOL_NAME.into()
+ use crate::{ContextServerRegistry, Templates, Thread};
+ use fs::FakeFs;
+ use gpui::{AppContext as _, TestAppContext};
+ use project::Project;
+ use prompt_store::ProjectContext;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ async fn create_thread_with_tools(cx: &mut TestAppContext) -> Entity<Thread> {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ });
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/test"), json!({})).await;
+ let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
+ let project_context = cx.new(|_cx| ProjectContext::default());
+ let context_server_store =
+ project.read_with(cx, |project, _| project.context_server_store());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
+ cx.new(|cx| {
+ let mut thread = Thread::new(
+ project,
+ project_context,
+ context_server_registry,
+ Templates::new(),
+ None,
+ cx,
+ );
+ thread.add_tool(crate::NowTool, None);
+ thread.add_tool(crate::ThinkingTool, None);
+ thread
+ })
}
- fn auth_methods(&self) -> &[acp::AuthMethod] {
- &[]
- }
+ #[gpui::test]
+ async fn test_validate_allowed_tools_succeeds_for_valid_tools(cx: &mut TestAppContext) {
+ let thread = create_thread_with_tools(cx).await;
- fn new_thread(
- self: Rc<Self>,
- _project: Entity<Project>,
- _cwd: &Path,
- _cx: &mut App,
- ) -> Task<Result<Entity<AcpThread>>> {
- unimplemented!("SubagentDisplayConnection does not support new_thread")
- }
+ cx.update(|cx| {
+ assert!(SubagentTool::validate_allowed_tools(&None, &thread, cx).is_ok());
- fn authenticate(&self, _method_id: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
- unimplemented!("SubagentDisplayConnection does not support authenticate")
- }
+ let valid_tools = Some(vec!["now".to_string()]);
+ assert!(SubagentTool::validate_allowed_tools(&valid_tools, &thread, cx).is_ok());
- fn prompt(
- &self,
- _id: Option<UserMessageId>,
- _params: acp::PromptRequest,
- _cx: &mut App,
- ) -> Task<Result<acp::PromptResponse>> {
- unimplemented!("SubagentDisplayConnection does not support prompt")
+ let both_tools = Some(vec!["now".to_string(), "thinking".to_string()]);
+ assert!(SubagentTool::validate_allowed_tools(&both_tools, &thread, cx).is_ok());
+ });
}
- fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
-
- fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
- self
+ #[gpui::test]
+ async fn test_validate_allowed_tools_fails_for_unknown_tools(cx: &mut TestAppContext) {
+ let thread = create_thread_with_tools(cx).await;
+
+ cx.update(|cx| {
+ let unknown_tools = Some(vec!["nonexistent_tool".to_string()]);
+ let result = SubagentTool::validate_allowed_tools(&unknown_tools, &thread, cx);
+ assert!(result.is_err());
+ let error_message = result.unwrap_err().to_string();
+ assert!(
+ error_message.contains("'nonexistent_tool'"),
+ "Expected error to mention the invalid tool name, got: {error_message}"
+ );
+
+ let mixed_tools = Some(vec![
+ "now".to_string(),
+ "fake_tool_a".to_string(),
+ "fake_tool_b".to_string(),
+ ]);
+ let result = SubagentTool::validate_allowed_tools(&mixed_tools, &thread, cx);
+ assert!(result.is_err());
+ let error_message = result.unwrap_err().to_string();
+ assert!(
+ error_message.contains("'fake_tool_a'") && error_message.contains("'fake_tool_b'"),
+ "Expected error to mention both invalid tool names, got: {error_message}"
+ );
+ assert!(
+ !error_message.contains("'now'"),
+ "Expected error to not mention valid tool 'now', got: {error_message}"
+ );
+ });
}
}
@@ -365,7 +365,7 @@ impl AgentConnection for AcpConnection {
self.telemetry_id.clone()
}
- fn new_thread(
+ fn new_session(
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
@@ -558,6 +558,7 @@ impl AgentConnection for AcpConnection {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread: Entity<AcpThread> = cx.new(|cx| {
AcpThread::new(
+ None,
self.server_name.clone(),
self.clone(),
project,
@@ -615,6 +616,7 @@ impl AgentConnection for AcpConnection {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread: Entity<AcpThread> = cx.new(|cx| {
AcpThread::new(
+ None,
self.server_name.clone(),
self.clone(),
project,
@@ -688,6 +690,7 @@ impl AgentConnection for AcpConnection {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread: Entity<AcpThread> = cx.new(|cx| {
AcpThread::new(
+ None,
self.server_name.clone(),
self.clone(),
project,
@@ -449,7 +449,7 @@ pub async fn new_test_thread(
.await
.unwrap();
- cx.update(|cx| connection.new_thread(project.clone(), current_dir.as_ref(), cx))
+ cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx))
.await
.unwrap()
}
@@ -75,6 +75,7 @@ impl EntryViewState {
match thread_entry {
AgentThreadEntry::UserMessage(message) => {
let has_id = message.id.is_some();
+ let is_subagent = thread.read(cx).parent_session_id().is_some();
let chunks = message.chunks.clone();
if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) {
if !editor.focus_handle(cx).is_focused(window) {
@@ -103,7 +104,7 @@ impl EntryViewState {
window,
cx,
);
- if !has_id {
+ if !has_id || is_subagent {
editor.set_read_only(true, cx);
}
editor.set_message(chunks, window, cx);
@@ -446,7 +447,7 @@ mod tests {
.update(|_, cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new(path!("/project")), cx)
+ .new_session(project.clone(), Path::new(path!("/project")), cx)
})
.await
.unwrap();
@@ -51,9 +51,9 @@ use text::{Anchor, ToPoint as _};
use theme::AgentFontSize;
use ui::{
Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon,
- DiffStat, Disclosure, Divider, DividerColor, IconButtonShape, IconDecoration,
- IconDecorationKind, KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor,
- Tooltip, WithScrollbar, prelude::*, right_click_menu,
+ DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, KeyBinding,
+ PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
+ right_click_menu,
};
use util::defer;
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
@@ -178,13 +178,35 @@ pub struct AcpServerView {
}
impl AcpServerView {
- pub fn as_active_thread(&self) -> Option<Entity<AcpThreadView>> {
+ pub fn active_thread(&self) -> Option<Entity<AcpThreadView>> {
match &self.server_state {
ServerState::Connected(connected) => Some(connected.current.clone()),
_ => None,
}
}
+ pub fn parent_thread(&self, cx: &App) -> Option<Entity<AcpThreadView>> {
+ match &self.server_state {
+ ServerState::Connected(connected) => {
+ let mut current = connected.current.clone();
+ while let Some(parent_id) = current.read(cx).parent_id.clone() {
+ if let Some(parent) = connected.threads.get(&parent_id) {
+ current = parent.clone();
+ } else {
+ break;
+ }
+ }
+ Some(current)
+ }
+ _ => None,
+ }
+ }
+
+ pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<AcpThreadView>> {
+ let connected = self.as_connected()?;
+ connected.threads.get(session_id).cloned()
+ }
+
pub fn as_connected(&self) -> Option<&ConnectedServerState> {
match &self.server_state {
ServerState::Connected(connected) => Some(connected),
@@ -198,6 +220,23 @@ impl AcpServerView {
_ => None,
}
}
+
+ pub fn navigate_to_session(
+ &mut self,
+ session_id: acp::SessionId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(connected) = self.as_connected_mut() else {
+ return;
+ };
+
+ connected.navigate_to_session(session_id);
+ if let Some(view) = self.active_thread() {
+ view.focus_handle(cx).focus(window, cx);
+ }
+ cx.notify();
+ }
}
enum ServerState {
@@ -211,6 +250,7 @@ enum ServerState {
pub struct ConnectedServerState {
auth_state: AuthState,
current: Entity<AcpThreadView>,
+ threads: HashMap<acp::SessionId, Entity<AcpThreadView>>,
connection: Rc<dyn AgentConnection>,
}
@@ -240,6 +280,23 @@ impl ConnectedServerState {
pub fn has_thread_error(&self, cx: &App) -> bool {
self.current.read(cx).thread_error.is_some()
}
+
+ pub fn navigate_to_session(&mut self, session_id: acp::SessionId) {
+ if let Some(session) = self.threads.get(&session_id) {
+ self.current = session.clone();
+ }
+ }
+
+ pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
+ let tasks = self
+ .threads
+ .keys()
+ .map(|id| self.connection.close_session(id, cx));
+ let task = futures::future::join_all(tasks);
+ cx.background_spawn(async move {
+ task.await;
+ })
+ }
}
impl AcpServerView {
@@ -255,9 +312,6 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
- let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
- let available_commands = Rc::new(RefCell::new(vec![]));
-
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = vec![
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
@@ -270,6 +324,9 @@ impl AcpServerView {
];
cx.on_release(|this, cx| {
+ if let Some(connected) = this.as_connected() {
+ connected.close_all_sessions(cx).detach();
+ }
for window in this.notifications.drain(..) {
window
.update(cx, |_, window, _| {
@@ -280,23 +337,17 @@ impl AcpServerView {
})
.detach();
- let workspace_for_state = workspace.clone();
- let project_for_state = project.clone();
-
Self {
agent: agent.clone(),
agent_server_store,
workspace,
- project,
+ project: project.clone(),
thread_store,
prompt_store,
server_state: Self::initial_state(
agent.clone(),
resume_thread,
- workspace_for_state,
- project_for_state,
- prompt_capabilities,
- available_commands,
+ project,
initial_content,
window,
cx,
@@ -311,30 +362,38 @@ impl AcpServerView {
}
}
- fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
- let available_commands = Rc::new(RefCell::new(vec![]));
+ fn set_server_state(&mut self, state: ServerState, cx: &mut Context<Self>) {
+ if let Some(connected) = self.as_connected() {
+ connected.close_all_sessions(cx).detach();
+ }
+
+ self.server_state = state;
+ cx.notify();
+ }
+ fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let resume_thread_metadata = self
- .as_active_thread()
+ .active_thread()
.and_then(|thread| thread.read(cx).resume_thread_metadata.clone());
- self.server_state = Self::initial_state(
+ let state = Self::initial_state(
self.agent.clone(),
resume_thread_metadata,
- self.workspace.clone(),
self.project.clone(),
- prompt_capabilities.clone(),
- available_commands.clone(),
None,
window,
cx,
);
+ self.set_server_state(state, cx);
if let Some(connected) = self.as_connected() {
connected.current.update(cx, |this, cx| {
this.message_editor.update(cx, |editor, cx| {
- editor.set_command_state(prompt_capabilities, available_commands, cx);
+ editor.set_command_state(
+ this.prompt_capabilities.clone(),
+ this.available_commands.clone(),
+ cx,
+ );
});
});
}
@@ -344,10 +403,7 @@ impl AcpServerView {
fn initial_state(
agent: Rc<dyn AgentServer>,
resume_thread: Option<AgentSessionInfo>,
- workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
- available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
initial_content: Option<ExternalAgentInitialContent>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -400,7 +456,7 @@ impl AcpServerView {
this.update_in(cx, |this, window, cx| {
if err.downcast_ref::<LoadError>().is_some() {
this.handle_load_error(err, window, cx);
- } else if let Some(active) = this.as_active_thread() {
+ } else if let Some(active) = this.active_thread() {
active.update(cx, |active, cx| active.handle_any_thread_error(err, cx));
}
cx.notify();
@@ -445,7 +501,7 @@ impl AcpServerView {
cx.update(|_, cx| {
connection
.clone()
- .new_thread(project.clone(), fallback_cwd.as_ref(), cx)
+ .new_session(project.clone(), fallback_cwd.as_ref(), cx)
})
.log_err()
};
@@ -471,181 +527,15 @@ impl AcpServerView {
this.update_in(cx, |this, window, cx| {
match result {
Ok(thread) => {
- let action_log = thread.read(cx).action_log().clone();
-
- prompt_capabilities.replace(thread.read(cx).prompt_capabilities());
-
- let entry_view_state = cx.new(|_| {
- EntryViewState::new(
- this.workspace.clone(),
- this.project.downgrade(),
- this.thread_store.clone(),
- this.history.downgrade(),
- this.prompt_store.clone(),
- prompt_capabilities.clone(),
- available_commands.clone(),
- this.agent.name(),
- )
- });
-
- let count = thread.read(cx).entries().len();
- let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
- entry_view_state.update(cx, |view_state, cx| {
- for ix in 0..count {
- view_state.sync_entry(ix, &thread, window, cx);
- }
- list_state.splice_focusable(
- 0..0,
- (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
- );
- });
-
- AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
-
- let connection = thread.read(cx).connection().clone();
- let session_id = thread.read(cx).session_id().clone();
- let session_list = if connection.supports_session_history(cx) {
- connection.session_list(cx)
- } else {
- None
- };
- this.history.update(cx, |history, cx| {
- history.set_session_list(session_list, cx);
- });
-
- // Check for config options first
- // Config options take precedence over legacy mode/model selectors
- // (feature flag gating happens at the data layer)
- let config_options_provider =
- connection.session_config_options(&session_id, cx);
-
- let config_options_view;
- let mode_selector;
- let model_selector;
- if let Some(config_options) = config_options_provider {
- // Use config options - don't create mode_selector or model_selector
- let agent_server = this.agent.clone();
- let fs = this.project.read(cx).fs().clone();
- config_options_view = Some(cx.new(|cx| {
- ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
- }));
- model_selector = None;
- mode_selector = None;
- } else {
- // Fall back to legacy mode/model selectors
- config_options_view = None;
- model_selector =
- connection.model_selector(&session_id).map(|selector| {
- let agent_server = this.agent.clone();
- let fs = this.project.read(cx).fs().clone();
- cx.new(|cx| {
- AcpModelSelectorPopover::new(
- selector,
- agent_server,
- fs,
- PopoverMenuHandle::default(),
- this.focus_handle(cx),
- window,
- cx,
- )
- })
- });
-
- mode_selector =
- connection
- .session_modes(&session_id, cx)
- .map(|session_modes| {
- let fs = this.project.read(cx).fs().clone();
- let focus_handle = this.focus_handle(cx);
- cx.new(|_cx| {
- ModeSelector::new(
- session_modes,
- this.agent.clone(),
- fs,
- focus_handle,
- )
- })
- });
- }
-
- let mut subscriptions = vec![
- cx.subscribe_in(&thread, window, Self::handle_thread_event),
- cx.observe(&action_log, |_, _, cx| cx.notify()),
- // cx.subscribe_in(
- // &entry_view_state,
- // window,
- // Self::handle_entry_view_event,
- // ),
- ];
-
- let title_editor =
- if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
- let editor = cx.new(|cx| {
- let mut editor = Editor::single_line(window, cx);
- editor.set_text(thread.read(cx).title(), window, cx);
- editor
- });
- subscriptions.push(cx.subscribe_in(
- &editor,
- window,
- Self::handle_title_editor_event,
- ));
- Some(editor)
- } else {
- None
- };
-
- let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
- connection.clone().downcast();
- let profile_selector = profile_selector
- .and_then(|native_connection| native_connection.thread(&session_id, cx))
- .map(|native_thread| {
- cx.new(|cx| {
- ProfileSelector::new(
- <dyn Fs>::global(cx),
- Arc::new(native_thread),
- this.focus_handle(cx),
- cx,
- )
- })
- });
-
- let agent_display_name = this
- .agent_server_store
- .read(cx)
- .agent_display_name(&ExternalAgentServerName(agent.name()))
- .unwrap_or_else(|| agent.name());
-
- let weak = cx.weak_entity();
- let current = cx.new(|cx| {
- AcpThreadView::new(
- thread,
- this.login.clone(),
- weak,
- agent.name(),
- agent_display_name,
- workspace.clone(),
- entry_view_state,
- title_editor,
- config_options_view,
- mode_selector,
- model_selector,
- profile_selector,
- list_state,
- prompt_capabilities,
- available_commands,
- resumed_without_history,
- resume_thread.clone(),
- project.downgrade(),
- this.thread_store.clone(),
- this.history.clone(),
- this.prompt_store.clone(),
- initial_content,
- subscriptions,
- window,
- cx,
- )
- });
+ let current = this.new_thread_view(
+ None,
+ thread,
+ resumed_without_history,
+ resume_thread,
+ initial_content,
+ window,
+ cx,
+ );
if this.focus_handle.contains_focused(window, cx) {
current
@@ -655,13 +545,18 @@ impl AcpServerView {
.focus(window, cx);
}
- this.server_state = ServerState::Connected(ConnectedServerState {
- connection,
- auth_state: AuthState::Ok,
- current,
- });
-
- cx.notify();
+ this.set_server_state(
+ ServerState::Connected(ConnectedServerState {
+ connection,
+ auth_state: AuthState::Ok,
+ current: current.clone(),
+ threads: HashMap::from_iter([(
+ current.read(cx).thread.read(cx).session_id().clone(),
+ current,
+ )]),
+ }),
+ cx,
+ );
}
Err(err) => {
this.handle_load_error(err, window, cx);
@@ -675,7 +570,7 @@ impl AcpServerView {
while let Ok(new_version) = new_version_available_rx.recv().await {
if let Some(new_version) = new_version {
this.update(cx, |this, cx| {
- if let Some(thread) = this.as_active_thread() {
+ if let Some(thread) = this.active_thread() {
thread.update(cx, |thread, _cx| {
thread.new_server_version_available = Some(new_version.into());
});
@@ -709,6 +604,211 @@ impl AcpServerView {
ServerState::Loading(loading_view)
}
+ fn new_thread_view(
+ &self,
+ parent_id: Option<acp::SessionId>,
+ thread: Entity<AcpThread>,
+ resumed_without_history: bool,
+ resume_thread: Option<AgentSessionInfo>,
+ initial_content: Option<ExternalAgentInitialContent>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Entity<AcpThreadView> {
+ let agent_name = self.agent.name();
+ let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
+ let available_commands = Rc::new(RefCell::new(vec![]));
+
+ let action_log = thread.read(cx).action_log().clone();
+
+ prompt_capabilities.replace(thread.read(cx).prompt_capabilities());
+
+ let entry_view_state = cx.new(|_| {
+ EntryViewState::new(
+ self.workspace.clone(),
+ self.project.downgrade(),
+ self.thread_store.clone(),
+ self.history.downgrade(),
+ self.prompt_store.clone(),
+ prompt_capabilities.clone(),
+ available_commands.clone(),
+ self.agent.name(),
+ )
+ });
+
+ let count = thread.read(cx).entries().len();
+ let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
+ entry_view_state.update(cx, |view_state, cx| {
+ for ix in 0..count {
+ view_state.sync_entry(ix, &thread, window, cx);
+ }
+ list_state.splice_focusable(
+ 0..0,
+ (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
+ );
+ });
+
+ AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
+
+ let connection = thread.read(cx).connection().clone();
+ let session_id = thread.read(cx).session_id().clone();
+ let session_list = if connection.supports_session_history(cx) {
+ connection.session_list(cx)
+ } else {
+ None
+ };
+ self.history.update(cx, |history, cx| {
+ history.set_session_list(session_list, cx);
+ });
+
+ // Check for config options first
+ // Config options take precedence over legacy mode/model selectors
+ // (feature flag gating happens at the data layer)
+ let config_options_provider = connection.session_config_options(&session_id, cx);
+
+ let config_options_view;
+ let mode_selector;
+ let model_selector;
+ if let Some(config_options) = config_options_provider {
+ // Use config options - don't create mode_selector or model_selector
+ let agent_server = self.agent.clone();
+ let fs = self.project.read(cx).fs().clone();
+ config_options_view =
+ Some(cx.new(|cx| {
+ ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
+ }));
+ model_selector = None;
+ mode_selector = None;
+ } else {
+ // Fall back to legacy mode/model selectors
+ config_options_view = None;
+ model_selector = connection.model_selector(&session_id).map(|selector| {
+ let agent_server = self.agent.clone();
+ let fs = self.project.read(cx).fs().clone();
+ cx.new(|cx| {
+ AcpModelSelectorPopover::new(
+ selector,
+ agent_server,
+ fs,
+ PopoverMenuHandle::default(),
+ self.focus_handle(cx),
+ window,
+ cx,
+ )
+ })
+ });
+
+ mode_selector = connection
+ .session_modes(&session_id, cx)
+ .map(|session_modes| {
+ let fs = self.project.read(cx).fs().clone();
+ let focus_handle = self.focus_handle(cx);
+ cx.new(|_cx| {
+ ModeSelector::new(session_modes, self.agent.clone(), fs, focus_handle)
+ })
+ });
+ }
+
+ let mut subscriptions = vec![
+ cx.subscribe_in(&thread, window, Self::handle_thread_event),
+ cx.observe(&action_log, |_, _, cx| cx.notify()),
+ ];
+
+ let parent_session_id = thread.read(cx).session_id().clone();
+ let subagent_sessions = thread
+ .read(cx)
+ .entries()
+ .iter()
+ .filter_map(|entry| match entry {
+ AgentThreadEntry::ToolCall(call) => call.subagent_session_id.clone(),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+
+ if !subagent_sessions.is_empty() {
+ cx.spawn_in(window, async move |this, cx| {
+ this.update_in(cx, |this, window, cx| {
+ for subagent_id in subagent_sessions {
+ this.load_subagent_session(
+ subagent_id,
+ parent_session_id.clone(),
+ window,
+ cx,
+ );
+ }
+ })
+ })
+ .detach();
+ }
+
+ let title_editor = if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_text(thread.read(cx).title(), window, cx);
+ editor
+ });
+ subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
+ Some(editor)
+ } else {
+ None
+ };
+
+ let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
+ connection.clone().downcast();
+ let profile_selector = profile_selector
+ .and_then(|native_connection| native_connection.thread(&session_id, cx))
+ .map(|native_thread| {
+ cx.new(|cx| {
+ ProfileSelector::new(
+ <dyn Fs>::global(cx),
+ Arc::new(native_thread),
+ self.focus_handle(cx),
+ cx,
+ )
+ })
+ });
+
+ let agent_display_name = self
+ .agent_server_store
+ .read(cx)
+ .agent_display_name(&ExternalAgentServerName(agent_name.clone()))
+ .unwrap_or_else(|| agent_name.clone());
+
+ let agent_icon = self.agent.logo();
+
+ let weak = cx.weak_entity();
+ cx.new(|cx| {
+ AcpThreadView::new(
+ parent_id,
+ thread,
+ self.login.clone(),
+ weak,
+ agent_icon,
+ agent_name,
+ agent_display_name,
+ self.workspace.clone(),
+ entry_view_state,
+ title_editor,
+ config_options_view,
+ mode_selector,
+ model_selector,
+ profile_selector,
+ list_state,
+ prompt_capabilities,
+ available_commands,
+ resumed_without_history,
+ resume_thread,
+ self.project.downgrade(),
+ self.thread_store.clone(),
+ self.history.clone(),
+ self.prompt_store.clone(),
+ initial_content,
+ subscriptions,
+ window,
+ cx,
+ )
+ })
+ }
+
fn handle_auth_required(
this: WeakEntity<Self>,
err: AuthRequired,
@@ -804,8 +904,7 @@ impl AcpServerView {
LoadError::Other(format!("{:#}", err).into())
};
self.emit_load_error_telemetry(&load_error);
- self.server_state = ServerState::LoadError(load_error);
- cx.notify();
+ self.set_server_state(ServerState::LoadError(load_error), cx);
}
fn handle_agent_servers_updated(
@@ -827,7 +926,7 @@ impl AcpServerView {
};
if should_retry {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
active.clear_thread_error(cx);
});
@@ -856,7 +955,7 @@ impl AcpServerView {
}
pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
active.cancel_generation(cx);
});
@@ -870,7 +969,7 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
active.handle_title_editor_event(title_editor, event, window, cx);
});
@@ -882,7 +981,7 @@ impl AcpServerView {
}
fn update_turn_tokens(&mut self, cx: &mut Context<Self>) {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
active.update_turn_tokens(cx);
});
@@ -896,7 +995,7 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
active.send_queued_message_at_index(index, is_send_now, window, cx);
});
@@ -910,11 +1009,13 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let thread_id = thread.read(cx).session_id().clone();
+ let is_subagent = thread.read(cx).parent_session_id().is_some();
match event {
AcpThreadEvent::NewEntry => {
let len = thread.read(cx).entries().len();
let index = len - 1;
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
entry_view_state.update(cx, |view_state, cx| {
@@ -930,7 +1031,7 @@ impl AcpServerView {
}
AcpThreadEvent::EntryUpdated(index) => {
if let Some(entry_view_state) = self
- .as_active_thread()
+ .thread_view(&thread_id)
.map(|active| active.read(cx).entry_view_state.clone())
{
entry_view_state.update(cx, |view_state, cx| {
@@ -939,29 +1040,39 @@ impl AcpServerView {
}
}
AcpThreadEvent::EntriesRemoved(range) => {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
list_state.splice(range.clone(), 0);
}
}
+ AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session(
+ session_id.clone(),
+ thread.read(cx).session_id().clone(),
+ window,
+ cx,
+ ),
AcpThreadEvent::ToolAuthorizationRequired => {
self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
}
AcpThreadEvent::Retry(retry) => {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, _cx| {
active.thread_retry_status = Some(retry.clone());
});
}
}
AcpThreadEvent::Stopped => {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, _cx| {
active.thread_retry_status.take();
});
}
+ if is_subagent {
+ return;
+ }
+
let used_tools = thread.read(cx).used_tools_since_last_user_message();
self.notify_with_sound(
if used_tools {
@@ -974,7 +1085,7 @@ impl AcpServerView {
cx,
);
- let should_send_queued = if let Some(active) = self.as_active_thread() {
+ let should_send_queued = if let Some(active) = self.active_thread() {
active.update(cx, |active, cx| {
if active.skip_queue_processing_count > 0 {
active.skip_queue_processing_count -= 1;
@@ -1005,29 +1116,33 @@ impl AcpServerView {
}
AcpThreadEvent::Refusal => {
let error = ThreadError::Refusal;
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, cx| {
active.handle_thread_error(error, cx);
active.thread_retry_status.take();
});
}
- let model_or_agent_name = self.current_model_name(cx);
- let notification_message =
- format!("{} refused to respond to this request", model_or_agent_name);
- self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
+ if !is_subagent {
+ let model_or_agent_name = self.current_model_name(cx);
+ let notification_message =
+ format!("{} refused to respond to this request", model_or_agent_name);
+ self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
+ }
}
AcpThreadEvent::Error => {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, _cx| {
active.thread_retry_status.take();
});
}
- self.notify_with_sound(
- "Agent stopped due to an error",
- IconName::Warning,
- window,
- cx,
- );
+ if !is_subagent {
+ self.notify_with_sound(
+ "Agent stopped due to an error",
+ IconName::Warning,
+ window,
+ cx,
+ );
+ }
}
AcpThreadEvent::LoadError(error) => {
match &self.server_state {
@@ -1044,12 +1159,12 @@ impl AcpServerView {
}
_ => {}
}
- self.server_state = ServerState::LoadError(error.clone());
+ self.set_server_state(ServerState::LoadError(error.clone()), cx);
}
AcpThreadEvent::TitleUpdated => {
let title = thread.read(cx).title();
if let Some(title_editor) = self
- .as_active_thread()
+ .thread_view(&thread_id)
.and_then(|active| active.read(cx).title_editor.clone())
{
title_editor.update(cx, |editor, cx| {
@@ -1061,7 +1176,7 @@ impl AcpServerView {
self.history.update(cx, |history, cx| history.refresh(cx));
}
AcpThreadEvent::PromptCapabilitiesUpdated => {
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, _cx| {
active
.prompt_capabilities
@@ -1088,7 +1203,7 @@ impl AcpServerView {
}
let has_commands = !available_commands.is_empty();
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
active.update(cx, |active, _cx| {
active.available_commands.replace(available_commands);
});
@@ -1100,7 +1215,7 @@ impl AcpServerView {
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
.unwrap_or_else(|| self.agent.name());
- if let Some(active) = self.as_active_thread() {
+ if let Some(active) = self.active_thread() {
let new_placeholder =
placeholder_text(agent_display_name.as_ref(), has_commands);
active.update(cx, |active, cx| {
@@ -1244,7 +1359,7 @@ impl AcpServerView {
{
pending_auth_method.take();
}
- if let Some(active) = this.as_active_thread() {
+ if let Some(active) = this.active_thread() {
active.update(cx, |active, cx| {
active.handle_any_thread_error(err, cx);
})
@@ -1359,7 +1474,7 @@ impl AcpServerView {
{
pending_auth_method.take();
}
- if let Some(active) = this.as_active_thread() {
+ if let Some(active) = this.active_thread() {
active.update(cx, |active, cx| active.handle_any_thread_error(err, cx));
}
} else {
@@ -1372,6 +1487,63 @@ impl AcpServerView {
}));
}
+ fn load_subagent_session(
+ &mut self,
+ subagent_id: acp::SessionId,
+ parent_id: acp::SessionId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(connected) = self.as_connected() else {
+ return;
+ };
+ if connected.threads.contains_key(&subagent_id)
+ || !connected.connection.supports_load_session(cx)
+ {
+ return;
+ }
+ let root_dir = self
+ .project
+ .read(cx)
+ .worktrees(cx)
+ .filter_map(|worktree| {
+ if worktree.read(cx).is_single_file() {
+ Some(worktree.read(cx).abs_path().parent()?.into())
+ } else {
+ Some(worktree.read(cx).abs_path())
+ }
+ })
+ .next();
+ let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into());
+
+ let subagent_thread_task = connected.connection.clone().load_session(
+ AgentSessionInfo::new(subagent_id.clone()),
+ self.project.clone(),
+ &cwd,
+ cx,
+ );
+
+ cx.spawn_in(window, async move |this, cx| {
+ let subagent_thread = subagent_thread_task.await?;
+ this.update_in(cx, |this, window, cx| {
+ let view = this.new_thread_view(
+ Some(parent_id),
+ subagent_thread,
+ false,
+ None,
+ None,
+ window,
+ cx,
+ );
+ let Some(connected) = this.as_connected_mut() else {
+ return;
+ };
+ connected.threads.insert(subagent_id, view);
+ })
+ })
+ .detach();
+ }
+
fn spawn_external_agent_login(
login: task::SpawnInTerminal,
workspace: Entity<Workspace>,
@@ -1492,7 +1664,7 @@ impl AcpServerView {
}
pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
- self.as_active_thread().is_some_and(|active| {
+ self.active_thread().is_some_and(|active| {
active
.read(cx)
.thread
@@ -1636,7 +1808,7 @@ impl AcpServerView {
thread: &Entity<AcpThread>,
cx: &mut Context<Self>,
) {
- let Some(active_thread) = self.as_active_thread() else {
+ let Some(active_thread) = self.active_thread() else {
return;
};
@@ -1790,18 +1962,18 @@ impl AcpServerView {
&self,
cx: &App,
) -> Option<Rc<agent::NativeAgentConnection>> {
- let acp_thread = self.as_active_thread()?.read(cx).thread.read(cx);
+ let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
acp_thread.connection().clone().downcast()
}
pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
- let acp_thread = self.as_active_thread()?.read(cx).thread.read(cx);
+ let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
self.as_native_connection(cx)?
.thread(acp_thread.session_id(), cx)
}
fn queued_messages_len(&self, cx: &App) -> usize {
- self.as_active_thread()
+ self.active_thread()
.map(|thread| thread.read(cx).local_queued_messages.len())
.unwrap_or_default()
}
@@ -1,7 +1,7 @@
use gpui::{Corner, List};
use language_model::LanguageModelEffortLevel;
use settings::update_settings_file;
-use ui::{ButtonLike, SplitButton, SplitButtonStyle};
+use ui::{ButtonLike, SplitButton, SplitButtonStyle, Tab};
use super::*;
@@ -167,10 +167,13 @@ impl DiffStats {
pub struct AcpThreadView {
pub id: acp::SessionId,
+ pub parent_id: Option<acp::SessionId>,
pub login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
pub thread: Entity<AcpThread>,
pub server_view: WeakEntity<AcpServerView>,
+ pub agent_icon: IconName,
pub agent_name: SharedString,
+ pub focus_handle: FocusHandle,
pub workspace: WeakEntity<Workspace>,
pub entry_view_state: Entity<EntryViewState>,
pub title_editor: Option<Entity<Editor>>,
@@ -234,7 +237,11 @@ pub struct AcpThreadView {
}
impl Focusable for AcpThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.active_editor(cx).focus_handle(cx)
+ if self.parent_id.is_some() {
+ self.focus_handle.clone()
+ } else {
+ self.active_editor(cx).focus_handle(cx)
+ }
}
}
@@ -250,9 +257,11 @@ pub struct TurnFields {
impl AcpThreadView {
pub fn new(
+ parent_id: Option<acp::SessionId>,
thread: Entity<AcpThread>,
login: Option<task::SpawnInTerminal>,
server_view: WeakEntity<AcpServerView>,
+ agent_icon: IconName,
agent_name: SharedString,
agent_display_name: SharedString,
workspace: WeakEntity<Workspace>,
@@ -339,9 +348,12 @@ impl AcpThreadView {
Self {
id,
+ parent_id,
+ focus_handle: cx.focus_handle(),
thread,
login,
server_view,
+ agent_icon,
agent_name,
workspace,
entry_view_state,
@@ -448,6 +460,10 @@ impl AcpThreadView {
}
}
+ fn is_subagent(&self) -> bool {
+ self.parent_id.is_some()
+ }
+
/// Returns the currently active editor, either for a message that is being
/// edited or the editor for a new message.
pub(crate) fn active_editor(&self, cx: &App) -> Entity<MessageEditor> {
@@ -1456,7 +1472,6 @@ impl AcpThreadView {
let client = project.read(cx).client();
let session_id = self.thread.read(cx).session_id().clone();
-
cx.spawn_in(window, async move |this, cx| {
let response = client
.request(proto::GetSharedAgentThread {
@@ -2281,11 +2296,51 @@ impl AcpThreadView {
)
}
+ pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
+ let Some(parent_session_id) = self.parent_id.clone() else {
+ return None;
+ };
+
+ let title = self.thread.read(cx).title();
+ let server_view = self.server_view.clone();
+
+ Some(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .pl_2()
+ .pr_1p5()
+ .w_full()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().editor_background.opacity(0.2))
+ .child(Label::new(title).color(Color::Muted))
+ .child(
+ IconButton::new("minimize_subagent", IconName::Minimize)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Minimize Subagent"))
+ .on_click(move |_, window, cx| {
+ let _ = server_view.update(cx, |server_view, cx| {
+ server_view.navigate_to_session(
+ parent_session_id.clone(),
+ window,
+ cx,
+ );
+ });
+ }),
+ ),
+ )
+ }
+
pub(crate) fn render_message_editor(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
+ if self.is_subagent() {
+ return div().into_any_element();
+ }
+
let focus_handle = self.message_editor.focus_handle(cx);
let editor_bg_color = cx.theme().colors().editor_background;
let editor_expanded = self.editor_expanded;
@@ -3234,6 +3289,14 @@ impl AcpThreadView {
.is_some_and(|checkpoint| checkpoint.show);
let agent_name = self.agent_name.clone();
+ let is_subagent = self.is_subagent();
+
+ let non_editable_icon = || {
+ IconButton::new("non_editable", IconName::PencilUnavailable)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .style(ButtonStyle::Transparent)
+ };
v_flex()
.id(("user_message", entry_ix))
@@ -3283,22 +3346,28 @@ impl AcpThreadView {
.py_3()
.px_2()
.rounded_md()
- .shadow_md()
.bg(cx.theme().colors().editor_background)
.border_1()
.when(is_indented, |this| {
this.py_2().px_2().shadow_sm()
})
- .when(editing && !editor_focus, |this| this.border_dashed())
.border_color(cx.theme().colors().border)
- .map(|this|{
+ .map(|this| {
+ if is_subagent {
+ return this.border_dashed();
+ }
if editing && editor_focus {
- this.border_color(focus_border)
- } else if message.id.is_some() {
- this.hover(|s| s.border_color(focus_border.opacity(0.8)))
- } else {
- this
+ return this.border_color(focus_border);
+ }
+ if editing && !editor_focus {
+ return this.border_dashed()
+ }
+ if message.id.is_some() {
+ return this.shadow_md().hover(|s| {
+ s.border_color(focus_border.opacity(0.8))
+ });
}
+ this
})
.text_xs()
.child(editor.clone().into_any_element())
@@ -3316,7 +3385,20 @@ impl AcpThreadView {
.overflow_hidden();
let is_loading_contents = self.is_loading_contents;
- if message.id.is_some() {
+ if is_subagent {
+ this.child(
+ base_container.border_dashed().child(
+ non_editable_icon().tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ "Unavailable Editing",
+ None,
+ "Editing subagent messages is currently not supported.",
+ cx,
+ )
+ }),
+ ),
+ )
+ } else if message.id.is_some() {
this.child(
base_container
.child(
@@ -3356,10 +3438,7 @@ impl AcpThreadView {
base_container
.border_dashed()
.child(
- IconButton::new("editing_unavailable", IconName::PencilUnavailable)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .style(ButtonStyle::Transparent)
+ non_editable_icon()
.tooltip(Tooltip::element({
move |_, _| {
v_flex()
@@ -4555,11 +4634,16 @@ impl AcpThreadView {
let is_edit =
matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
- let is_subagent = tool_call.is_subagent();
// For subagent tool calls, render the subagent cards directly without wrapper
- if is_subagent {
- return self.render_subagent_tool_call(entry_ix, tool_call, window, cx);
+ if tool_call.is_subagent() {
+ return self.render_subagent_tool_call(
+ entry_ix,
+ tool_call,
+ tool_call.subagent_session_id.clone(),
+ window,
+ cx,
+ );
}
let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
@@ -5243,6 +5327,7 @@ impl AcpThreadView {
) -> Div {
let has_location = tool_call.locations.len() == 1;
let is_file = tool_call.kind == acp::ToolKind::Edit && has_location;
+ let is_subagent_tool_call = tool_call.is_subagent();
let file_icon = if has_location {
FileIcons::get_icon(&tool_call.locations[0].path, cx)
@@ -5274,25 +5359,27 @@ impl AcpThreadView {
.into_any_element()
} else if is_file {
div().child(file_icon).into_any_element()
- } else {
- div()
- .child(
- Icon::new(match tool_call.kind {
- acp::ToolKind::Read => IconName::ToolSearch,
- acp::ToolKind::Edit => IconName::ToolPencil,
- acp::ToolKind::Delete => IconName::ToolDeleteFile,
- acp::ToolKind::Move => IconName::ArrowRightLeft,
- acp::ToolKind::Search => IconName::ToolSearch,
- acp::ToolKind::Execute => IconName::ToolTerminal,
- acp::ToolKind::Think => IconName::ToolThink,
- acp::ToolKind::Fetch => IconName::ToolWeb,
- acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
- acp::ToolKind::Other | _ => IconName::ToolHammer,
- })
- .size(IconSize::Small)
- .color(Color::Muted),
- )
+ } else if is_subagent_tool_call {
+ Icon::new(self.agent_icon)
+ .size(IconSize::Small)
+ .color(Color::Muted)
.into_any_element()
+ } else {
+ Icon::new(match tool_call.kind {
+ acp::ToolKind::Read => IconName::ToolSearch,
+ acp::ToolKind::Edit => IconName::ToolPencil,
+ acp::ToolKind::Delete => IconName::ToolDeleteFile,
+ acp::ToolKind::Move => IconName::ArrowRightLeft,
+ acp::ToolKind::Search => IconName::ToolSearch,
+ acp::ToolKind::Execute => IconName::ToolTerminal,
+ acp::ToolKind::Think => IconName::ToolThink,
+ acp::ToolKind::Fetch => IconName::ToolWeb,
+ acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
+ acp::ToolKind::Other | _ => IconName::ToolHammer,
+ })
+ .size(IconSize::Small)
+ .color(Color::Muted)
+ .into_any_element()
};
let gradient_overlay = {
@@ -5478,10 +5565,6 @@ impl AcpThreadView {
ToolCallContent::Terminal(terminal) => {
self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
}
- ToolCallContent::SubagentThread(_thread) => {
- // Subagent threads are rendered by render_subagent_tool_call, not here
- Empty.into_any_element()
- }
}
}
@@ -5715,54 +5798,56 @@ impl AcpThreadView {
&self,
entry_ix: usize,
tool_call: &ToolCall,
+ subagent_session_id: Option<acp::SessionId>,
window: &Window,
cx: &Context<Self>,
) -> Div {
- let subagent_threads: Vec<_> = tool_call
- .content
- .iter()
- .filter_map(|c| c.subagent_thread().cloned())
- .collect();
-
let tool_call_status = &tool_call.status;
- v_flex()
- .mx_5()
- .my_1p5()
- .gap_3()
- .children(
- subagent_threads
- .into_iter()
- .enumerate()
- .map(|(context_ix, thread)| {
- self.render_subagent_card(
- entry_ix,
- context_ix,
- &thread,
- tool_call_status,
- window,
- cx,
- )
- }),
- )
+ let subagent_thread_view = subagent_session_id.and_then(|id| {
+ self.server_view
+ .upgrade()
+ .and_then(|server_view| server_view.read(cx).as_connected())
+ .and_then(|connected| connected.threads.get(&id))
+ });
+
+ let content = self.render_subagent_card(
+ entry_ix,
+ 0,
+ subagent_thread_view,
+ tool_call_status,
+ window,
+ cx,
+ );
+
+ v_flex().mx_5().my_1p5().gap_3().child(content)
}
fn render_subagent_card(
&self,
entry_ix: usize,
context_ix: usize,
- thread: &Entity<AcpThread>,
+ thread_view: Option<&Entity<AcpThreadView>>,
tool_call_status: &ToolCallStatus,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
- let thread_read = thread.read(cx);
- let session_id = thread_read.session_id().clone();
- let title = thread_read.title();
- let action_log = thread_read.action_log();
- let changed_buffers = action_log.read(cx).changed_buffers(cx);
-
- let is_expanded = self.expanded_subagents.contains(&session_id);
+ let thread = thread_view
+ .as_ref()
+ .map(|view| view.read(cx).thread.clone());
+ let session_id = thread
+ .as_ref()
+ .map(|thread| thread.read(cx).session_id().clone());
+ let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log());
+ let changed_buffers = action_log
+ .map(|log| log.read(cx).changed_buffers(cx))
+ .unwrap_or_default();
+
+ let is_expanded = if let Some(session_id) = &session_id {
+ self.expanded_subagents.contains(session_id)
+ } else {
+ false
+ };
let files_changed = changed_buffers.len();
let diff_stats = DiffStats::all_files(&changed_buffers, cx);
@@ -5775,9 +5860,20 @@ impl AcpThreadView {
ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
);
- let card_header_id =
- SharedString::from(format!("subagent-header-{}-{}", entry_ix, context_ix));
- let diff_stat_id = SharedString::from(format!("subagent-diff-{}-{}", entry_ix, context_ix));
+ let title = thread
+ .as_ref()
+ .map(|t| t.read(cx).title())
+ .unwrap_or_else(|| {
+ if is_canceled_or_failed {
+ "Subagent Canceled"
+ } else {
+ "Spawning Subagentβ¦"
+ }
+ .into()
+ });
+
+ let card_header_id = format!("subagent-header-{}-{}", entry_ix, context_ix);
+ let diff_stat_id = format!("subagent-diff-{}-{}", entry_ix, context_ix);
let icon = h_flex().w_4().justify_center().child(if is_running {
SpinnerLabel::new()
@@ -5795,15 +5891,17 @@ impl AcpThreadView {
.into_any_element()
});
- let has_expandable_content = thread_read.entries().iter().rev().any(|entry| {
- if let AgentThreadEntry::AssistantMessage(msg) = entry {
- msg.chunks.iter().any(|chunk| match chunk {
- AssistantMessageChunk::Message { block } => block.markdown().is_some(),
- AssistantMessageChunk::Thought { block } => block.markdown().is_some(),
- })
- } else {
- false
- }
+ let has_expandable_content = thread.as_ref().map_or(false, |thread| {
+ thread.read(cx).entries().iter().rev().any(|entry| {
+ if let AgentThreadEntry::AssistantMessage(msg) = entry {
+ msg.chunks.iter().any(|chunk| match chunk {
+ AssistantMessageChunk::Message { block } => block.markdown().is_some(),
+ AssistantMessageChunk::Thought { block } => block.markdown().is_some(),
+ })
+ } else {
+ false
+ }
+ })
});
v_flex()
@@ -5815,8 +5913,8 @@ impl AcpThreadView {
.child(
h_flex()
.group(&card_header_id)
- .py_1()
- .px_1p5()
+ .p_1()
+ .pl_1p5()
.w_full()
.gap_1()
.justify_between()
@@ -5825,11 +5923,7 @@ impl AcpThreadView {
h_flex()
.gap_1p5()
.child(icon)
- .child(
- Label::new(title.to_string())
- .size(LabelSize::Small)
- .color(Color::Default),
- )
+ .child(Label::new(title.to_string()).size(LabelSize::Small))
.when(files_changed > 0, |this| {
this.child(
h_flex()
@@ -5851,95 +5945,126 @@ impl AcpThreadView {
)
}),
)
- .child(
- h_flex()
- .gap_1p5()
- .when(is_running, |buttons| {
- buttons.child(
- Button::new(
- SharedString::from(format!(
- "stop-subagent-{}-{}",
- entry_ix, context_ix
- )),
- "Stop",
+ .when_some(session_id, |this, session_id| {
+ this.child(
+ h_flex()
+ .when(has_expandable_content, |this| {
+ this.child(
+ IconButton::new(
+ format!(
+ "subagent-disclosure-{}-{}",
+ entry_ix, context_ix
+ ),
+ if is_expanded {
+ IconName::ChevronUp
+ } else {
+ IconName::ChevronDown
+ },
+ )
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .disabled(!has_expandable_content)
+ .visible_on_hover(card_header_id.clone())
+ .on_click(
+ cx.listener({
+ let session_id = session_id.clone();
+ move |this, _, _, cx| {
+ if this.expanded_subagents.contains(&session_id)
+ {
+ this.expanded_subagents.remove(&session_id);
+ } else {
+ this.expanded_subagents
+ .insert(session_id.clone());
+ }
+ cx.notify();
+ }
+ }),
+ ),
+ )
+ })
+ .child(
+ IconButton::new(
+ format!("expand-subagent-{}-{}", entry_ix, context_ix),
+ IconName::Maximize,
)
- .icon(IconName::Stop)
- .icon_position(IconPosition::Start)
+ .icon_color(Color::Muted)
.icon_size(IconSize::Small)
- .icon_color(Color::Error)
- .label_size(LabelSize::Small)
- .tooltip(Tooltip::text("Stop this subagent"))
- .on_click({
- let thread = thread.clone();
- cx.listener(move |_this, _event, _window, cx| {
- thread.update(cx, |thread, _cx| {
- thread.stop_by_user();
- });
- })
- }),
- )
- })
- .child(
- IconButton::new(
- SharedString::from(format!(
- "subagent-disclosure-{}-{}",
- entry_ix, context_ix
+ .tooltip(Tooltip::text("Expand Subagent"))
+ .visible_on_hover(card_header_id)
+ .on_click(cx.listener(
+ move |this, _event, window, cx| {
+ this.server_view
+ .update(cx, |this, cx| {
+ this.navigate_to_session(
+ session_id.clone(),
+ window,
+ cx,
+ );
+ })
+ .ok();
+ },
)),
- if is_expanded {
- IconName::ChevronUp
- } else {
- IconName::ChevronDown
- },
)
- .shape(IconButtonShape::Square)
- .icon_color(Color::Muted)
- .icon_size(IconSize::Small)
- .disabled(!has_expandable_content)
- .when(has_expandable_content, |button| {
- button.on_click(cx.listener({
- move |this, _, _, cx| {
- if this.expanded_subagents.contains(&session_id) {
- this.expanded_subagents.remove(&session_id);
- } else {
- this.expanded_subagents.insert(session_id.clone());
- }
- cx.notify();
- }
- }))
- })
- .when(
- !has_expandable_content,
- |button| {
- button.tooltip(Tooltip::text("Waiting for content..."))
- },
- ),
- ),
- ),
+ .when(is_running, |buttons| {
+ buttons.child(
+ IconButton::new(
+ format!("stop-subagent-{}-{}", entry_ix, context_ix),
+ IconName::Stop,
+ )
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .tooltip(Tooltip::text("Stop Subagent"))
+ .when_some(
+ thread_view
+ .as_ref()
+ .map(|view| view.read(cx).thread.clone()),
+ |this, thread| {
+ this.on_click(cx.listener(
+ move |_this, _event, _window, cx| {
+ thread.update(cx, |thread, _cx| {
+ thread.stop_by_user();
+ });
+ },
+ ))
+ },
+ ),
+ )
+ }),
+ )
+ }),
)
- .when(is_expanded, |this| {
- this.child(
- self.render_subagent_expanded_content(entry_ix, context_ix, thread, window, cx),
+ .when_some(thread_view, |this, thread_view| {
+ let thread = &thread_view.read(cx).thread;
+ this.when(is_expanded, |this| {
+ this.child(
+ self.render_subagent_expanded_content(
+ entry_ix, context_ix, thread, window, cx,
+ ),
+ )
+ })
+ .children(
+ thread
+ .read(cx)
+ .first_tool_awaiting_confirmation()
+ .and_then(|tc| {
+ if let ToolCallStatus::WaitingForConfirmation { options, .. } =
+ &tc.status
+ {
+ Some(self.render_subagent_pending_tool_call(
+ entry_ix,
+ context_ix,
+ thread.clone(),
+ tc,
+ options,
+ window,
+ cx,
+ ))
+ } else {
+ None
+ }
+ }),
)
})
- .children(
- thread_read
- .first_tool_awaiting_confirmation()
- .and_then(|tc| {
- if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tc.status {
- Some(self.render_subagent_pending_tool_call(
- entry_ix,
- context_ix,
- thread.clone(),
- tc,
- options,
- window,
- cx,
- ))
- } else {
- None
- }
- }),
- )
.into_any_element()
}
@@ -6841,6 +6966,7 @@ impl AcpThreadView {
}
fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
+ let server_view = self.server_view.clone();
v_flex().w_full().justify_end().child(
h_flex()
.p_2()
@@ -6865,11 +6991,11 @@ impl AcpThreadView {
Button::new("update-button", format!("Update to v{}", version))
.label_size(LabelSize::Small)
.style(ButtonStyle::Tinted(TintColor::Accent))
- .on_click(cx.listener(|this, _, window, cx| {
- this.server_view
+ .on_click(move |_, window, cx| {
+ server_view
.update(cx, |view, cx| view.reset(window, cx))
.ok();
- })),
+ }),
),
)
}
@@ -7028,8 +7154,20 @@ impl Render for AcpThreadView {
v_flex()
.key_context("AcpThread")
+ .track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Cancel, _, cx| {
- this.cancel_generation(cx);
+ if this.parent_id.is_none() {
+ this.cancel_generation(cx);
+ }
+ }))
+ .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| {
+ if let Some(parent_session_id) = this.parent_id.clone() {
+ this.server_view
+ .update(cx, |view, cx| {
+ view.navigate_to_session(parent_session_id, window, cx);
+ })
+ .ok();
+ }
}))
.on_action(cx.listener(Self::keep_all))
.on_action(cx.listener(Self::reject_all))
@@ -7153,6 +7291,7 @@ impl Render for AcpThreadView {
}
}))
.size_full()
+ .children(self.render_subagent_titlebar(cx))
.child(conversation)
.children(self.render_activity_bar(window, cx))
.when(self.show_codex_windows_warning, |this| {
@@ -1360,6 +1360,7 @@ impl AgentDiff {
}
AcpThreadEvent::TitleUpdated
| AcpThreadEvent::TokenUsageUpdated
+ | AcpThreadEvent::SubagentSpawned(_)
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequired
| AcpThreadEvent::PromptCapabilitiesUpdated
@@ -1761,7 +1762,7 @@ mod tests {
.update(|cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new(path!("/test")), cx)
+ .new_session(project.clone(), Path::new(path!("/test")), cx)
})
.await
.unwrap();
@@ -1942,7 +1943,7 @@ mod tests {
.update(|_, cx| {
connection
.clone()
- .new_thread(project.clone(), Path::new(path!("/test")), cx)
+ .new_session(project.clone(), Path::new(path!("/test")), cx)
})
.await
.unwrap();
@@ -157,7 +157,7 @@ pub fn init(cx: &mut App) {
.and_then(|thread_view| {
thread_view
.read(cx)
- .as_active_thread()
+ .active_thread()
.map(|r| r.read(cx).thread.clone())
});
@@ -922,7 +922,7 @@ impl AgentPanel {
return;
};
- let Some(active_thread) = thread_view.read(cx).as_active_thread() else {
+ let Some(active_thread) = thread_view.read(cx).active_thread() else {
return;
};
@@ -1195,7 +1195,7 @@ impl AgentPanel {
) {
if let Some(workspace) = self.workspace.upgrade()
&& let Some(thread_view) = self.active_thread_view()
- && let Some(active_thread) = thread_view.read(cx).as_active_thread()
+ && let Some(active_thread) = thread_view.read(cx).active_thread()
{
active_thread.update(cx, |thread, cx| {
thread
@@ -1423,7 +1423,7 @@ impl AgentPanel {
match &self.active_view {
ActiveView::AgentThread { thread_view, .. } => thread_view
.read(cx)
- .as_active_thread()
+ .active_thread()
.map(|r| r.read(cx).thread.clone()),
_ => None,
}
@@ -1851,7 +1851,7 @@ impl AgentPanel {
if let Some(title_editor) = thread_view
.read(cx)
- .as_active_thread()
+ .parent_thread(cx)
.and_then(|r| r.read(cx).title_editor.clone())
{
let container = div()
@@ -142,7 +142,7 @@ impl AgentThreadPane {
fn title(&self, cx: &App) -> SharedString {
if let Some(active_thread_view) = &self.thread_view {
let thread_view = active_thread_view.view.read(cx);
- if let Some(ready) = thread_view.as_active_thread() {
+ if let Some(ready) = thread_view.active_thread() {
let title = ready.read(cx).thread.read(cx).title();
if !title.is_empty() {
return title;
@@ -328,6 +328,9 @@ impl ExampleContext {
"{}Bug: Tool confirmation should not be required in eval",
log_prefix
),
+ ThreadEvent::SubagentSpawned(session) => {
+ println!("{log_prefix} Got subagent spawn: {session:?}");
+ }
ThreadEvent::Retry(status) => {
println!("{log_prefix} Got retry: {status:?}");
}
@@ -323,7 +323,7 @@ impl ExampleInstance {
};
thread.update(cx, |thread, cx| {
- thread.add_default_tools(Rc::new(EvalThreadEnvironment {
+ thread.add_default_tools(None, Rc::new(EvalThreadEnvironment {
project: project.clone(),
}), cx);
thread.set_profile(meta.profile_id.clone(), cx);
@@ -679,6 +679,18 @@ impl agent::ThreadEnvironment for EvalThreadEnvironment {
Ok(Rc::new(EvalTerminalHandle { terminal }) as Rc<dyn agent::TerminalHandle>)
})
}
+
+ fn create_subagent(
+ &self,
+ _parent_thread: Entity<agent::Thread>,
+ _label: String,
+ _initial_prompt: String,
+ _timeout_ms: Option<Duration>,
+ _allowed_tools: Option<Vec<String>>,
+ _cx: &mut App,
+ ) -> Result<Rc<dyn agent::SubagentHandle>> {
+ unimplemented!()
+ }
}
struct LanguageModelInterceptor {
@@ -69,7 +69,6 @@ use {
time::Duration,
},
util::ResultExt as _,
- watch,
workspace::{AppState, Workspace},
zed_actions::OpenSettingsAt,
};
@@ -465,26 +464,6 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
- // Run Test 4: Subagent Cards visual tests
- #[cfg(feature = "visual-tests")]
- {
- println!("\n--- Test 4: subagent_cards (running, completed, expanded) ---");
- match run_subagent_visual_tests(app_state.clone(), &mut cx, update_baseline) {
- Ok(TestResult::Passed) => {
- println!("β subagent_cards: PASSED");
- passed += 1;
- }
- Ok(TestResult::BaselineUpdated(_)) => {
- println!("β subagent_cards: Baselines updated");
- updated += 1;
- }
- Err(e) => {
- eprintln!("β subagent_cards: FAILED - {}", e);
- failed += 1;
- }
- }
- }
-
// Run Test 5: Breakpoint Hover visual tests
println!("\n--- Test 5: breakpoint_hover (3 variants) ---");
match run_breakpoint_hover_visual_tests(app_state.clone(), &mut cx, update_baseline) {
@@ -1927,337 +1906,6 @@ impl AgentServer for StubAgentServer {
}
}
-#[cfg(all(target_os = "macos", feature = "visual-tests"))]
-fn run_subagent_visual_tests(
- app_state: Arc<AppState>,
- cx: &mut VisualTestAppContext,
- update_baseline: bool,
-) -> Result<TestResult> {
- use acp_thread::{
- AcpThread, SUBAGENT_TOOL_NAME, ToolCallUpdateSubagentThread, meta_with_tool_name,
- };
- use agent_ui::AgentPanel;
-
- // Create a temporary project directory
- let temp_dir = tempfile::tempdir()?;
- let temp_path = temp_dir.keep();
- let canonical_temp = temp_path.canonicalize()?;
- let project_path = canonical_temp.join("project");
- std::fs::create_dir_all(&project_path)?;
-
- // Create a project
- let project = cx.update(|cx| {
- project::Project::local(
- app_state.client.clone(),
- app_state.node_runtime.clone(),
- app_state.user_store.clone(),
- app_state.languages.clone(),
- app_state.fs.clone(),
- None,
- project::LocalProjectFlags {
- init_worktree_trust: false,
- ..Default::default()
- },
- cx,
- )
- });
-
- // Add the test directory as a worktree
- let add_worktree_task = project.update(cx, |project, cx| {
- project.find_or_create_worktree(&project_path, true, cx)
- });
-
- cx.foreground_executor
- .block_test(add_worktree_task)
- .log_err();
-
- cx.run_until_parked();
-
- // Create stub connection - we'll manually inject the subagent content
- let connection = StubAgentConnection::new();
-
- // Create a subagent tool call (in progress state)
- let tool_call = acp::ToolCall::new("subagent-tool-1", "2 subagents")
- .kind(acp::ToolKind::Other)
- .meta(meta_with_tool_name(SUBAGENT_TOOL_NAME))
- .status(acp::ToolCallStatus::InProgress);
-
- connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
-
- let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection.clone()));
-
- // Create a window sized for the agent panel
- let window_size = size(px(600.0), px(700.0));
- let bounds = Bounds {
- origin: point(px(0.0), px(0.0)),
- size: window_size,
- };
-
- let workspace_window: WindowHandle<Workspace> = cx
- .update(|cx| {
- cx.open_window(
- WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(bounds)),
- focus: false,
- show: false,
- ..Default::default()
- },
- |window, cx| {
- cx.new(|cx| {
- Workspace::new(None, project.clone(), app_state.clone(), window, cx)
- })
- },
- )
- })
- .context("Failed to open agent window")?;
-
- cx.run_until_parked();
-
- // Load the AgentPanel
- let (weak_workspace, async_window_cx) = workspace_window
- .update(cx, |workspace, window, cx| {
- (workspace.weak_handle(), window.to_async(cx))
- })
- .context("Failed to get workspace handle")?;
-
- let prompt_builder =
- cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
- let panel = cx
- .foreground_executor
- .block_test(AgentPanel::load(
- weak_workspace,
- prompt_builder,
- async_window_cx,
- ))
- .context("Failed to load AgentPanel")?;
-
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- workspace_window
- .update(cx, |workspace, window, cx| {
- workspace.add_panel(panel.clone(), window, cx);
- workspace.open_panel::<AgentPanel>(window, cx);
- })
- .log_err();
- })?;
-
- cx.run_until_parked();
-
- // Open the stub thread
- cx.update_window(workspace_window.into(), |_, window, cx| {
- panel.update(cx, |panel: &mut agent_ui::AgentPanel, cx| {
- panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
- });
- })?;
-
- cx.run_until_parked();
-
- // Get the thread view and send a message to trigger the subagent tool call
- let thread_view = cx
- .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
- .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
-
- let thread = cx
- .read(|cx| {
- thread_view
- .read(cx)
- .as_active_thread()
- .map(|active| active.read(cx).thread.clone())
- })
- .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
-
- // Send the message to trigger the subagent response
- let send_future = thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
- thread.send(vec!["Run two subagents".into()], cx)
- });
-
- cx.foreground_executor.block_test(send_future).log_err();
-
- cx.run_until_parked();
-
- // Get the tool call ID
- let tool_call_id = cx
- .read(|cx| {
- thread.read(cx).entries().iter().find_map(|entry| {
- if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
- Some(tool_call.id.clone())
- } else {
- None
- }
- })
- })
- .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
-
- // Create two subagent AcpThreads and inject them
- let subagent1 = cx.update(|cx| {
- let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
- let session_id = acp::SessionId::new("subagent-1");
- cx.new(|cx| {
- let mut thread = AcpThread::new(
- "Exploring test-repo",
- Rc::new(connection.clone()),
- project.clone(),
- action_log,
- session_id,
- watch::Receiver::constant(acp::PromptCapabilities::new()),
- cx,
- );
- // Add some content to this subagent
- thread.push_assistant_content_block(
- "## Summary of test-repo\n\nThis is a test repository with:\n\n- **Files:** test.txt\n- **Purpose:** Testing".into(),
- false,
- cx,
- );
- thread
- })
- });
-
- let subagent2 = cx.update(|cx| {
- let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
- let session_id = acp::SessionId::new("subagent-2");
- cx.new(|cx| {
- let mut thread = AcpThread::new(
- "Exploring test-worktree",
- Rc::new(connection.clone()),
- project.clone(),
- action_log,
- session_id,
- watch::Receiver::constant(acp::PromptCapabilities::new()),
- cx,
- );
- // Add some content to this subagent
- thread.push_assistant_content_block(
- "## Summary of test-worktree\n\nThis directory contains:\n\n- A single `config.json` file\n- Basic project setup".into(),
- false,
- cx,
- );
- thread
- })
- });
-
- // Inject subagent threads into the tool call
- thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
- thread
- .update_tool_call(
- ToolCallUpdateSubagentThread {
- id: tool_call_id.clone(),
- thread: subagent1,
- },
- cx,
- )
- .log_err();
- thread
- .update_tool_call(
- ToolCallUpdateSubagentThread {
- id: tool_call_id.clone(),
- thread: subagent2,
- },
- cx,
- )
- .log_err();
- });
-
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
-
- cx.run_until_parked();
-
- // Capture subagents in RUNNING state (tool call still in progress)
- let running_result = run_visual_test(
- "subagent_cards_running",
- workspace_window.into(),
- cx,
- update_baseline,
- )?;
-
- // Now mark the tool call as completed by updating it through the thread
- thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
- thread
- .handle_session_update(
- acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
- tool_call_id.clone(),
- acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
- )),
- cx,
- )
- .log_err();
- });
-
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
-
- cx.run_until_parked();
-
- // Capture subagents in COMPLETED state
- let completed_result = run_visual_test(
- "subagent_cards_completed",
- workspace_window.into(),
- cx,
- update_baseline,
- )?;
-
- // Expand the first subagent
- thread_view.update(cx, |view: &mut agent_ui::acp::AcpServerView, cx| {
- view.expand_subagent(acp::SessionId::new("subagent-1"), cx);
- });
-
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
-
- cx.run_until_parked();
-
- // Capture subagent in EXPANDED state
- let expanded_result = run_visual_test(
- "subagent_cards_expanded",
- workspace_window.into(),
- cx,
- update_baseline,
- )?;
-
- // Cleanup
- workspace_window
- .update(cx, |workspace, _window, cx| {
- let project = workspace.project().clone();
- project.update(cx, |project, cx| {
- let worktree_ids: Vec<_> =
- project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
- for id in worktree_ids {
- project.remove_worktree(id, cx);
- }
- });
- })
- .log_err();
-
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.remove_window();
- })
- .log_err();
-
- cx.run_until_parked();
-
- for _ in 0..15 {
- cx.advance_clock(Duration::from_millis(100));
- cx.run_until_parked();
- }
-
- match (&running_result, &completed_result, &expanded_result) {
- (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
- (TestResult::BaselineUpdated(p), _, _)
- | (_, TestResult::BaselineUpdated(p), _)
- | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
- }
-}
-
#[cfg(all(target_os = "macos", feature = "visual-tests"))]
fn run_agent_thread_view_test(
app_state: Arc<AppState>,
@@ -2471,7 +2119,7 @@ fn run_agent_thread_view_test(
.read(|cx| {
thread_view
.read(cx)
- .as_active_thread()
+ .active_thread()
.map(|active| active.read(cx).thread.clone())
})
.ok_or_else(|| anyhow::anyhow!("Thread not available"))?;