Detailed changes
@@ -1083,6 +1083,7 @@
"save_file": true,
"open": true,
"grep": true,
+ "subagent": true,
"terminal": true,
"thinking": true,
"web_search": true,
@@ -1102,6 +1103,7 @@
"read_file": true,
"open": true,
"grep": true,
+ "subagent": true,
"thinking": true,
"web_search": true,
},
@@ -1522,7 +1522,12 @@ impl AcpThread {
.push(ToolCallContent::Terminal(update.terminal));
}
ToolCallUpdate::UpdateSubagentThread(update) => {
- call.content.clear();
+ 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));
}
@@ -11,9 +11,13 @@ path = "src/action_log.rs"
[lints]
workspace = true
+[features]
+test-support = []
+
[dependencies]
anyhow.workspace = true
buffer_diff.workspace = true
+log.workspace = true
clock.workspace = true
collections.workspace = true
futures.workspace = true
@@ -774,6 +774,15 @@ impl ActionLog {
.collect()
}
+ /// Returns all tracked buffers for debugging purposes
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn tracked_buffers_for_debug(
+ &self,
+ _cx: &App,
+ ) -> impl Iterator<Item = (&Entity<Buffer>, &TrackedBuffer)> {
+ self.tracked_buffers.iter()
+ }
+
/// Iterate over buffers changed since last read or edited by the model
pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
self.tracked_buffers
@@ -973,7 +982,7 @@ enum TrackedBufferStatus {
Deleted,
}
-struct TrackedBuffer {
+pub struct TrackedBuffer {
buffer: Entity<Buffer>,
diff_base: Rope,
unreviewed_edits: Patch<u32>,
@@ -988,6 +997,16 @@ struct TrackedBuffer {
}
impl TrackedBuffer {
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn diff(&self) -> &Entity<BufferDiff> {
+ &self.diff
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn diff_base_len(&self) -> usize {
+ self.diff_base.len()
+ }
+
fn has_edits(&self, cx: &App) -> bool {
self.diff
.read(cx)
@@ -22,7 +22,6 @@ use gpui::{
http_client::FakeHttpClient,
};
use indoc::indoc;
-
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest,
@@ -2992,7 +2991,10 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
acp::ToolCall::new("1", "Thinking")
.kind(acp::ToolKind::Think)
.raw_input(json!({}))
- .meta(acp_thread::meta_with_tool_name("thinking"))
+ .meta(acp::Meta::from_iter([(
+ "tool_name".into(),
+ "thinking".into()
+ )]))
);
let update = expect_tool_call_update_fields(&mut events).await;
assert_eq!(
@@ -3968,47 +3970,6 @@ async fn test_subagent_tool_is_present_when_feature_flag_enabled(cx: &mut TestAp
});
}
-#[gpui::test]
-async fn test_subagent_tool_is_absent_when_feature_flag_disabled(cx: &mut TestAppContext) {
- init_test(cx);
-
- cx.update(|cx| {
- cx.update_flags(false, vec![]);
- });
-
- 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 handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
- let environment = Rc::new(FakeThreadEnvironment { handle });
-
- let thread = cx.new(|cx| {
- let mut thread = Thread::new(
- project.clone(),
- project_context,
- context_server_registry,
- Templates::new(),
- Some(model),
- cx,
- );
- thread.add_default_tools(environment, cx);
- thread
- });
-
- thread.read_with(cx, |thread, _| {
- assert!(
- !thread.has_registered_tool("subagent"),
- "subagent tool should not be present when feature flag is disabled"
- );
- });
-}
-
#[gpui::test]
async fn test_subagent_thread_inherits_parent_model(cx: &mut TestAppContext) {
init_test(cx);
@@ -4042,6 +4003,7 @@ async fn test_subagent_thread_inherits_parent_model(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4089,6 +4051,7 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
);
thread.add_default_tools(environment, cx);
@@ -4135,6 +4098,7 @@ async fn test_subagent_receives_task_prompt(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4193,6 +4157,7 @@ async fn test_subagent_returns_summary_on_completion(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4267,6 +4232,7 @@ async fn test_allowed_tools_restricts_subagent_capabilities(cx: &mut TestAppCont
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
);
thread.add_tool(EchoTool);
@@ -4348,6 +4314,7 @@ async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4377,6 +4344,95 @@ async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+async fn test_subagent_tool_cancellation(cx: &mut TestAppContext) {
+ // This test verifies that the subagent tool properly handles user cancellation
+ // via `event_stream.cancelled_by_user()` and stops all running subagents.
+ 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_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 parent = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ project_context.clone(),
+ context_server_registry.clone(),
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+
+ let parent_tools: std::collections::BTreeMap<gpui::SharedString, Arc<dyn crate::AnyAgentTool>> =
+ std::collections::BTreeMap::new();
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(SubagentTool::new(
+ parent.downgrade(),
+ project.clone(),
+ project_context,
+ context_server_registry,
+ Templates::new(),
+ 0,
+ parent_tools,
+ ));
+
+ let (event_stream, _rx, mut cancellation_tx) =
+ crate::ToolCallEventStream::test_with_cancellation();
+
+ // Start the subagent tool
+ let task = cx.update(|cx| {
+ tool.run(
+ SubagentToolInput {
+ subagents: vec![crate::SubagentConfig {
+ 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,
+ }],
+ },
+ event_stream.clone(),
+ cx,
+ )
+ });
+
+ cx.run_until_parked();
+
+ // Signal cancellation via the event stream
+ crate::ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx);
+
+ // The task should complete promptly with a cancellation error
+ let timeout = cx.background_executor.timer(Duration::from_secs(5));
+ let result = futures::select! {
+ result = task.fuse() => result,
+ _ = timeout.fuse() => {
+ panic!("subagent tool did not respond to cancellation within timeout");
+ }
+ };
+
+ // Verify we got a cancellation error
+ let err = result.unwrap_err();
+ assert!(
+ err.to_string().contains("cancelled by user"),
+ "expected cancellation error, got: {}",
+ err
+ );
+}
+
#[gpui::test]
async fn test_subagent_model_error_returned_as_tool_error(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
@@ -4408,6 +4464,7 @@ async fn test_subagent_model_error_returned_as_tool_error(cx: &mut TestAppContex
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4466,6 +4523,7 @@ async fn test_subagent_timeout_triggers_early_summary(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context.clone(),
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4547,6 +4605,7 @@ async fn test_context_low_check_returns_true_when_usage_high(cx: &mut TestAppCon
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4614,8 +4673,13 @@ async fn test_allowed_tools_rejects_unknown_tool(cx: &mut TestAppContext) {
thread
});
- let parent_tool_names: Vec<gpui::SharedString> = vec!["echo".into()];
+ let mut parent_tools: std::collections::BTreeMap<
+ gpui::SharedString,
+ Arc<dyn crate::AnyAgentTool>,
+ > = std::collections::BTreeMap::new();
+ parent_tools.insert("echo".into(), EchoTool.erase());
+ #[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(SubagentTool::new(
parent.downgrade(),
project,
@@ -4623,10 +4687,18 @@ async fn test_allowed_tools_rejects_unknown_tool(cx: &mut TestAppContext) {
context_server_registry,
Templates::new(),
0,
- parent_tool_names,
+ parent_tools,
));
- let result = tool.validate_allowed_tools(&Some(vec!["nonexistent_tool".to_string()]));
+ let subagent_configs = vec![crate::SubagentConfig {
+ label: "Test".to_string(),
+ task_prompt: "Do something".to_string(),
+ summary_prompt: "Summarize".to_string(),
+ context_low_prompt: "Context low".to_string(),
+ timeout_ms: None,
+ allowed_tools: Some(vec!["nonexistent_tool".to_string()]),
+ }];
+ let result = tool.validate_subagents(&subagent_configs);
assert!(result.is_err(), "should reject unknown tool");
let err_msg = result.unwrap_err().to_string();
assert!(
@@ -4635,8 +4707,8 @@ async fn test_allowed_tools_rejects_unknown_tool(cx: &mut TestAppContext) {
err_msg
);
assert!(
- err_msg.contains("not available"),
- "error should explain the tool is not available: {}",
+ err_msg.contains("do not exist"),
+ "error should explain the tool does not exist: {}",
err_msg
);
}
@@ -4672,6 +4744,7 @@ async fn test_subagent_empty_response_handled(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4727,6 +4800,7 @@ async fn test_nested_subagent_at_depth_2_succeeds(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
depth_1_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4752,6 +4826,7 @@ async fn test_nested_subagent_at_depth_2_succeeds(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
depth_2_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4810,6 +4885,7 @@ async fn test_subagent_uses_tool_and_returns_result(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
);
thread.add_tool(EchoTool);
@@ -4906,6 +4982,7 @@ async fn test_max_parallel_subagents_enforced(cx: &mut TestAppContext) {
Templates::new(),
model.clone(),
subagent_context,
+ std::collections::BTreeMap::new(),
cx,
)
});
@@ -4924,8 +5001,10 @@ async fn test_max_parallel_subagents_enforced(cx: &mut TestAppContext) {
);
});
- let parent_tool_names: Vec<gpui::SharedString> = vec![];
+ let parent_tools: std::collections::BTreeMap<gpui::SharedString, Arc<dyn crate::AnyAgentTool>> =
+ std::collections::BTreeMap::new();
+ #[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(SubagentTool::new(
parent.downgrade(),
project.clone(),
@@ -4933,7 +5012,7 @@ async fn test_max_parallel_subagents_enforced(cx: &mut TestAppContext) {
context_server_registry,
Templates::new(),
0,
- parent_tool_names,
+ parent_tools,
));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
@@ -4941,12 +5020,14 @@ async fn test_max_parallel_subagents_enforced(cx: &mut TestAppContext) {
let result = cx.update(|cx| {
tool.run(
SubagentToolInput {
- label: "Test".to_string(),
- task_prompt: "Do something".to_string(),
- summary_prompt: "Summarize".to_string(),
- context_low_prompt: "Context low".to_string(),
- timeout_ms: None,
- allowed_tools: None,
+ subagents: vec![crate::SubagentConfig {
+ label: "Test".to_string(),
+ task_prompt: "Do something".to_string(),
+ summary_prompt: "Summarize".to_string(),
+ context_low_prompt: "Context low".to_string(),
+ timeout_ms: None,
+ allowed_tools: None,
+ }],
},
event_stream,
cx,
@@ -4995,8 +5076,13 @@ async fn test_subagent_tool_end_to_end(cx: &mut TestAppContext) {
thread
});
- let parent_tool_names: Vec<gpui::SharedString> = vec!["echo".into()];
+ let mut parent_tools: std::collections::BTreeMap<
+ gpui::SharedString,
+ Arc<dyn crate::AnyAgentTool>,
+ > = std::collections::BTreeMap::new();
+ parent_tools.insert("echo".into(), EchoTool.erase());
+ #[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(SubagentTool::new(
parent.downgrade(),
project.clone(),
@@ -5004,7 +5090,7 @@ async fn test_subagent_tool_end_to_end(cx: &mut TestAppContext) {
context_server_registry,
Templates::new(),
0,
- parent_tool_names,
+ parent_tools,
));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
@@ -5012,12 +5098,14 @@ async fn test_subagent_tool_end_to_end(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
SubagentToolInput {
- label: "Research task".to_string(),
- task_prompt: "Find all TODOs in the codebase".to_string(),
- summary_prompt: "Summarize what you found".to_string(),
- context_low_prompt: "Context low, wrap up".to_string(),
- timeout_ms: None,
- allowed_tools: None,
+ subagents: vec![crate::SubagentConfig {
+ label: "Research task".to_string(),
+ task_prompt: "Find all TODOs in the codebase".to_string(),
+ summary_prompt: "Summarize what you found".to_string(),
+ context_low_prompt: "Context low, wrap up".to_string(),
+ timeout_ms: None,
+ allowed_tools: None,
+ }],
},
event_stream,
cx,
@@ -720,6 +720,7 @@ impl Thread {
templates: Arc<Templates>,
model: Arc<dyn LanguageModel>,
subagent_context: SubagentContext,
+ parent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
cx: &mut Context<Self>,
) -> Self {
let profile_id = AgentSettings::get_global(cx).default_profile.clone();
@@ -739,7 +740,7 @@ impl Thread {
completion_mode: AgentSettings::get_global(cx).preferred_completion_mode,
running_turn: None,
pending_message: None,
- tools: BTreeMap::default(),
+ tools: parent_tools,
tool_use_limit_reached: false,
request_token_usage: HashMap::default(),
cumulative_token_usage: TokenUsage::default(),
@@ -1107,7 +1108,7 @@ impl Thread {
self.add_tool(WebSearchTool);
if cx.has_flag::<SubagentsFeatureFlag>() && self.depth() < MAX_SUBAGENT_DEPTH {
- let tool_names = self.registered_tool_names();
+ let parent_tools = self.tools.clone();
self.add_tool(SubagentTool::new(
cx.weak_entity(),
self.project.clone(),
@@ -1115,7 +1116,7 @@ impl Thread {
self.context_server_registry.clone(),
self.templates.clone(),
self.depth(),
- tool_names,
+ parent_tools,
));
}
}
@@ -2780,8 +2781,14 @@ pub struct ToolCallEventStream {
impl ToolCallEventStream {
#[cfg(any(test, feature = "test-support"))]
pub fn test() -> (Self, ToolCallEventStreamReceiver) {
+ let (stream, receiver, _cancellation_tx) = Self::test_with_cancellation();
+ (stream, receiver)
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test_with_cancellation() -> (Self, ToolCallEventStreamReceiver, watch::Sender<bool>) {
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
- let (_cancellation_tx, cancellation_rx) = watch::channel(false);
+ let (cancellation_tx, cancellation_rx) = watch::channel(false);
let stream = ToolCallEventStream::new(
"test_id".into(),
@@ -2790,7 +2797,17 @@ impl ToolCallEventStream {
cancellation_rx,
);
- (stream, ToolCallEventStreamReceiver(events_rx))
+ (
+ stream,
+ ToolCallEventStreamReceiver(events_rx),
+ cancellation_tx,
+ )
+ }
+
+ /// Signal cancellation for this event stream. Only available in tests.
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn signal_cancellation_with_sender(cancellation_tx: &mut watch::Sender<bool>) {
+ cancellation_tx.send(true).ok();
}
fn new(
@@ -20,6 +20,8 @@ mod thinking_tool;
mod web_search_tool;
use crate::AgentTool;
+use feature_flags::{FeatureFlagAppExt, SubagentsFeatureFlag};
+use gpui::App;
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
pub use context_server_registry::*;
@@ -46,8 +48,8 @@ pub use web_search_tool::*;
macro_rules! tools {
($($tool:ty),* $(,)?) => {
/// A list of all built-in tool names
- pub fn supported_built_in_tool_names(provider: Option<language_model::LanguageModelProviderId>) -> impl Iterator<Item = String> {
- [
+ pub fn supported_built_in_tool_names(provider: Option<language_model::LanguageModelProviderId>, cx: &App) -> Vec<String> {
+ let mut tools: Vec<String> = [
$(
(if let Some(provider) = provider.as_ref() {
<$tool>::supports_provider(provider)
@@ -59,6 +61,13 @@ macro_rules! tools {
]
.into_iter()
.flatten()
+ .collect();
+
+ if !cx.has_flag::<SubagentsFeatureFlag>() {
+ tools.retain(|name| name != SubagentTool::name());
+ }
+
+ tools
}
/// A list of all built-in tools
@@ -96,6 +105,7 @@ tools! {
ReadFileTool,
RestoreFileFromDiskTool,
SaveFileTool,
+ SubagentTool,
TerminalTool,
ThinkingTool,
WebSearchTool,
@@ -2,9 +2,10 @@ use acp_thread::{AcpThread, AgentConnection, UserMessageId};
use action_log::ActionLog;
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
-use collections::HashSet;
-use futures::channel::mpsc;
+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 prompt_store::ProjectContext;
use schemars::JsonSchema;
@@ -19,37 +20,47 @@ use util::ResultExt;
use watch;
use crate::{
- AgentTool, ContextServerRegistry, MAX_PARALLEL_SUBAGENTS, MAX_SUBAGENT_DEPTH, SubagentContext,
- Templates, Thread, ThreadEvent, ToolCallAuthorization, ToolCallEventStream,
+ AgentTool, AnyAgentTool, ContextServerRegistry, MAX_PARALLEL_SUBAGENTS, MAX_SUBAGENT_DEPTH,
+ SubagentContext, Templates, 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;
-/// Spawns a subagent with its own context window to perform a delegated task.
+/// Spawns one or more subagents with their own context windows to perform delegated tasks.
+/// Multiple subagents run in parallel.
///
-/// Use this tool when you need to:
-/// - Perform research that would consume too many tokens in the main context
-/// - Execute a complex subtask independently
-/// - Run multiple parallel investigations
+/// Use this tool when you want to do any of the following:
+/// - Perform an investigation where all you need to know is the outcome, not the research that led to that outcome.
+/// - Complete a self-contained task where you need to know if it succeeded or failed (and how), but none of its intermediate output.
+/// - Run multiple tasks in parallel that would take significantly longer to run sequentially.
///
-/// You control what the subagent does by providing:
+/// You control what each subagent does by providing:
/// 1. A task prompt describing what the subagent should do
/// 2. A summary prompt that tells the subagent how to summarize its work when done
/// 3. A "context running out" prompt for when the subagent is low on tokens
///
-/// The subagent has access to the same tools you do. You can optionally restrict
-/// which tools the subagent can use.
+/// Each subagent has access to the same tools you do. You can optionally restrict
+/// which tools each subagent can use.
///
-/// IMPORTANT:
-/// - Maximum 8 subagents can be spawned per turn
+/// Note:
+/// - Maximum 8 subagents can run in parallel
/// - Subagents cannot use tools you don't have access to
/// - If spawning multiple subagents that might write to the filesystem, provide
-/// guidance on how to avoid conflicts (e.g., assign each to different directories)
+/// guidance on how to avoid conflicts (e.g. assign each to different directories)
/// - Instruct subagents to be concise in their summaries to conserve your context
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SubagentToolInput {
+ /// The list of subagents to spawn. At least one is required.
+ /// All subagents run in parallel and their results are collected.
+ #[schemars(length(min = 1, max = 8))]
+ pub subagents: Vec<SubagentConfig>,
+}
+
+/// Configuration for a single subagent.
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct SubagentConfig {
/// Short label displayed in the UI while the subagent runs (e.g., "Researching alternatives")
pub label: String,
@@ -83,6 +94,7 @@ pub struct SubagentToolInput {
pub allowed_tools: Option<Vec<String>>,
}
+/// Tool that spawns subagent threads to work on tasks in parallel.
pub struct SubagentTool {
parent_thread: WeakEntity<Thread>,
project: Entity<Project>,
@@ -90,7 +102,10 @@ pub struct SubagentTool {
context_server_registry: Entity<ContextServerRegistry>,
templates: Arc<Templates>,
current_depth: u8,
- parent_tool_names: HashSet<SharedString>,
+ /// The tools available to the parent thread, captured before SubagentTool was added.
+ /// Subagents inherit from this set (or a subset via `allowed_tools` in the config).
+ /// This is captured early so subagents don't get the subagent tool themselves.
+ parent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
}
impl SubagentTool {
@@ -101,7 +116,7 @@ impl SubagentTool {
context_server_registry: Entity<ContextServerRegistry>,
templates: Arc<Templates>,
current_depth: u8,
- parent_tool_names: Vec<SharedString>,
+ parent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
) -> Self {
Self {
parent_thread,
@@ -110,22 +125,48 @@ impl SubagentTool {
context_server_registry,
templates,
current_depth,
- parent_tool_names: parent_tool_names.into_iter().collect(),
+ parent_tools,
}
}
- pub fn validate_allowed_tools(&self, allowed_tools: &Option<Vec<String>>) -> Result<()> {
- if let Some(tools) = allowed_tools {
- for tool in tools {
- if !self.parent_tool_names.contains(tool.as_str()) {
- return Err(anyhow!(
- "Tool '{}' is not available to the parent agent. Available tools: {:?}",
- tool,
- self.parent_tool_names.iter().collect::<Vec<_>>()
- ));
+ pub fn validate_subagents(&self, subagents: &[SubagentConfig]) -> Result<()> {
+ if subagents.is_empty() {
+ return Err(anyhow!("At least one subagent configuration is required"));
+ }
+
+ if subagents.len() > MAX_PARALLEL_SUBAGENTS {
+ return Err(anyhow!(
+ "Maximum {} subagents can be spawned at once, but {} were requested",
+ MAX_PARALLEL_SUBAGENTS,
+ subagents.len()
+ ));
+ }
+
+ // Collect all invalid tools across all subagents
+ let mut all_invalid_tools: Vec<String> = Vec::new();
+ for config in subagents {
+ if let Some(ref tools) = config.allowed_tools {
+ for tool in tools {
+ if !self.parent_tools.contains_key(tool.as_str())
+ && !all_invalid_tools.contains(tool)
+ {
+ all_invalid_tools.push(tool.clone());
+ }
}
}
}
+
+ if !all_invalid_tools.is_empty() {
+ return Err(anyhow!(
+ "The following tools do not exist: {}",
+ all_invalid_tools
+ .iter()
+ .map(|t| format!("'{}'", t))
+ .collect::<Vec<_>>()
+ .join(", ")
+ ));
+ }
+
Ok(())
}
}
@@ -148,8 +189,14 @@ impl AgentTool for SubagentTool {
_cx: &mut App,
) -> SharedString {
input
- .map(|i| i.label.into())
- .unwrap_or_else(|_| "Subagent".into())
+ .map(|i| {
+ if i.subagents.len() == 1 {
+ i.subagents[0].label.clone().into()
+ } else {
+ format!("{} subagents", i.subagents.len()).into()
+ }
+ })
+ .unwrap_or_else(|_| "Subagents".into())
}
fn run(
@@ -165,7 +212,7 @@ impl AgentTool for SubagentTool {
)));
}
- if let Err(e) = self.validate_allowed_tools(&input.allowed_tools) {
+ if let Err(e) = self.validate_subagents(&input.subagents) {
return Task::ready(Err(e));
}
@@ -177,96 +224,182 @@ impl AgentTool for SubagentTool {
};
let running_count = parent_thread.read(cx).running_subagent_count();
- if running_count >= MAX_PARALLEL_SUBAGENTS {
+ let available_slots = MAX_PARALLEL_SUBAGENTS.saturating_sub(running_count);
+ if available_slots == 0 {
return Task::ready(Err(anyhow!(
"Maximum parallel subagents ({}) reached. Wait for existing subagents to complete.",
MAX_PARALLEL_SUBAGENTS
)));
}
- let parent_thread_id = parent_thread.read(cx).id().clone();
- let parent_model = parent_thread.read(cx).model().cloned();
- let tool_use_id = event_stream.tool_use_id().clone();
+ if input.subagents.len() > available_slots {
+ return Task::ready(Err(anyhow!(
+ "Cannot spawn {} subagents: only {} slots available (max {} parallel)",
+ input.subagents.len(),
+ available_slots,
+ MAX_PARALLEL_SUBAGENTS
+ )));
+ }
+ let parent_model = parent_thread.read(cx).model().cloned();
let Some(model) = parent_model else {
return Task::ready(Err(anyhow!("No model configured")));
};
- let subagent_context = SubagentContext {
- parent_thread_id,
- tool_use_id,
- depth: self.current_depth + 1,
- summary_prompt: input.summary_prompt.clone(),
- context_low_prompt: input.context_low_prompt.clone(),
- };
-
+ let parent_thread_id = parent_thread.read(cx).id().clone();
let project = self.project.clone();
let project_context = self.project_context.clone();
let context_server_registry = self.context_server_registry.clone();
let templates = self.templates.clone();
- let task_prompt = input.task_prompt;
- let timeout_ms = input.timeout_ms;
- let allowed_tools: Option<HashSet<SharedString>> = input
- .allowed_tools
- .map(|tools| tools.into_iter().map(SharedString::from).collect());
+ let parent_tools = self.parent_tools.clone();
+ let current_depth = self.current_depth;
+ let parent_thread_weak = self.parent_thread.clone();
- let parent_thread = self.parent_thread.clone();
+ // Spawn all subagents in parallel
+ let subagent_configs = input.subagents;
cx.spawn(async move |cx| {
- let subagent_thread: Entity<Thread> = cx.new(|cx| {
- Thread::new_subagent(
- project.clone(),
- project_context.clone(),
- context_server_registry.clone(),
- templates.clone(),
- model,
- subagent_context,
- cx,
- )
- });
+ // Create all subagent threads upfront so we can track them for cancellation
+ let mut subagent_data: Vec<(
+ String, // label
+ Entity<Thread>, // subagent thread
+ Entity<AcpThread>, // acp thread for display
+ String, // task prompt
+ Option<u64>, // timeout
+ )> = Vec::new();
+
+ for config in subagent_configs {
+ 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: config.summary_prompt.clone(),
+ context_low_prompt: config.context_low_prompt.clone(),
+ };
+
+ // Determine which tools this subagent gets
+ let subagent_tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>> =
+ if let Some(ref allowed) = config.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 label = config.label.clone();
+ let task_prompt = config.task_prompt.clone();
+ let timeout_ms = config.timeout_ms;
+
+ 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(
- "Subagent",
- connection,
- project.clone(),
- action_log,
- session_id,
- watch::Receiver::constant(acp::PromptCapabilities::new()),
- 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(
+ &label,
+ connection,
+ project.clone(),
+ action_log,
+ session_id,
+ watch::Receiver::constant(acp::PromptCapabilities::new()),
+ cx,
+ )
+ });
- event_stream.update_subagent_thread(acp_thread.clone());
+ event_stream.update_subagent_thread(acp_thread.clone());
- if let Some(parent) = parent_thread.upgrade() {
- parent.update(cx, |thread, _cx| {
- thread.register_running_subagent(subagent_weak.clone());
- });
+ if let Some(parent) = parent_thread_weak.upgrade() {
+ parent.update(cx, |thread, _cx| {
+ thread.register_running_subagent(subagent_weak.clone());
+ });
+ }
+
+ subagent_data.push((label, subagent_thread, acp_thread, task_prompt, timeout_ms));
}
- let result = run_subagent(
- &subagent_thread,
- &acp_thread,
- allowed_tools,
- task_prompt,
- timeout_ms,
- cx,
- )
- .await;
-
- if let Some(parent) = parent_thread.upgrade() {
- let _ = parent.update(cx, |thread, _cx| {
- thread.unregister_running_subagent(&subagent_weak);
- });
+ // Collect weak refs for cancellation cleanup
+ let subagent_threads: Vec<WeakEntity<Thread>> = subagent_data
+ .iter()
+ .map(|(_, thread, _, _, _)| thread.downgrade())
+ .collect();
+
+ // Spawn tasks for each subagent
+ let tasks: Vec<_> = subagent_data
+ .into_iter()
+ .map(
+ |(label, subagent_thread, acp_thread, task_prompt, timeout_ms)| {
+ let parent_thread_weak = parent_thread_weak.clone();
+ cx.spawn(async move |cx| {
+ let subagent_weak = subagent_thread.downgrade();
+
+ let result = run_subagent(
+ &subagent_thread,
+ &acp_thread,
+ task_prompt,
+ timeout_ms,
+ cx,
+ )
+ .await;
+
+ if let Some(parent) = parent_thread_weak.upgrade() {
+ let _ = parent.update(cx, |thread, _cx| {
+ thread.unregister_running_subagent(&subagent_weak);
+ });
+ }
+
+ (label, result)
+ })
+ },
+ )
+ .collect();
+
+ // Wait for all subagents to complete, or cancellation
+ let results: Vec<(String, Result<String>)> = futures::select! {
+ results = futures::future::join_all(tasks).fuse() => results,
+ _ = event_stream.cancelled_by_user().fuse() => {
+ // Cancel all running subagents
+ for subagent_weak in &subagent_threads {
+ if let Some(subagent) = subagent_weak.upgrade() {
+ let _ = subagent.update(cx, |thread, cx| {
+ thread.cancel(cx).detach();
+ });
+ }
+ }
+ anyhow::bail!("Subagent tool cancelled by user");
+ }
+ };
+
+ // Format the combined results
+ let mut output = String::new();
+ for (label, result) in &results {
+ output.push_str(&format!("## {}\n\n", label));
+ match result {
+ Ok(summary) => output.push_str(&summary),
+ Err(e) => output.push_str(&format!("Error: {}", e)),
+ }
+ output.push_str("\n\n");
}
- result
+ Ok(output.trim().to_string())
})
}
}
@@ -274,17 +407,10 @@ impl AgentTool for SubagentTool {
async fn run_subagent(
subagent_thread: &Entity<Thread>,
acp_thread: &Entity<AcpThread>,
- allowed_tools: Option<HashSet<SharedString>>,
task_prompt: String,
timeout_ms: Option<u64>,
cx: &mut AsyncApp,
) -> Result<String> {
- if let Some(ref allowed) = allowed_tools {
- subagent_thread.update(cx, |thread, _cx| {
- thread.restrict_tools(allowed);
- });
- }
-
let mut events_rx =
subagent_thread.update(cx, |thread, cx| thread.submit_user_message(task_prompt, cx))?;
@@ -476,26 +602,52 @@ mod tests {
);
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"
+ properties.get("subagents").is_some(),
+ "should have subagents field"
+ );
+
+ let subagents_schema = properties.get("subagents").unwrap();
+ assert!(
+ subagents_schema.get("items").is_some(),
+ "subagents should have items schema"
+ );
+
+ // The items use a $ref to definitions/SubagentConfig, so we need to look up
+ // the actual schema in the definitions section
+ let definitions = schema_json
+ .get("definitions")
+ .expect("schema should have definitions");
+ let subagent_config_schema = definitions
+ .get("SubagentConfig")
+ .expect("definitions should have SubagentConfig");
+ let item_properties = subagent_config_schema
+ .get("properties")
+ .expect("SubagentConfig should have properties");
+
+ assert!(
+ item_properties.get("label").is_some(),
+ "subagent item should have label field"
+ );
+ assert!(
+ item_properties.get("task_prompt").is_some(),
+ "subagent item should have task_prompt field"
);
assert!(
- properties.get("summary_prompt").is_some(),
- "should have summary_prompt field"
+ item_properties.get("summary_prompt").is_some(),
+ "subagent item should have summary_prompt field"
);
assert!(
- properties.get("context_low_prompt").is_some(),
- "should have context_low_prompt field"
+ item_properties.get("context_low_prompt").is_some(),
+ "subagent item should have context_low_prompt field"
);
assert!(
- properties.get("timeout_ms").is_some(),
- "should have timeout_ms field"
+ item_properties.get("timeout_ms").is_some(),
+ "subagent item should have timeout_ms field"
);
assert!(
- properties.get("allowed_tools").is_some(),
- "should have allowed_tools field"
+ item_properties.get("allowed_tools").is_some(),
+ "subagent item should have allowed_tools field"
);
}
@@ -343,6 +343,7 @@ pub struct AcpThreadView {
expanded_terminal_commands: HashSet<acp::ToolCallId>,
expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
expanded_thinking_blocks: HashSet<(usize, usize)>,
+ expanded_subagents: HashSet<acp::SessionId>,
edits_expanded: bool,
plan_expanded: bool,
queue_expanded: bool,
@@ -528,6 +529,7 @@ impl AcpThreadView {
expanded_terminal_commands: HashSet::default(),
expanded_tool_call_raw_inputs: HashSet::default(),
expanded_thinking_blocks: HashSet::default(),
+ expanded_subagents: HashSet::default(),
editing_message: None,
edits_expanded: false,
plan_expanded: false,
@@ -2960,6 +2962,13 @@ 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);
+ }
+
let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled);
let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| {
self.entry_view_state
@@ -3470,14 +3479,208 @@ impl AcpThreadView {
self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
}
ToolCallContent::SubagentThread(_thread) => {
- // The subagent's AcpThread entity stores the subagent's conversation
- // (messages, tool calls, etc.) but we don't render it here. The entity
- // is used for serialization (e.g., to_markdown) and data storage, not display.
+ // Subagent threads are rendered by render_subagent_tool_call, not here
Empty.into_any_element()
}
}
}
+ fn render_subagent_tool_call(
+ &self,
+ entry_ix: usize,
+ tool_call: &ToolCall,
+ 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_in_progress = matches!(
+ tool_call.status,
+ ToolCallStatus::Pending | ToolCallStatus::InProgress
+ );
+
+ v_flex().ml_5().mr_5().my_1p5().gap_1().children(
+ subagent_threads
+ .into_iter()
+ .enumerate()
+ .map(|(context_ix, thread)| {
+ self.render_subagent_card(
+ entry_ix,
+ context_ix,
+ &thread,
+ tool_call_in_progress,
+ window,
+ cx,
+ )
+ }),
+ )
+ }
+
+ fn render_subagent_card(
+ &self,
+ entry_ix: usize,
+ context_ix: usize,
+ thread: &Entity<AcpThread>,
+ tool_call_in_progress: bool,
+ 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 files_changed = changed_buffers.len();
+ let diff_stats = DiffStats::all_files(&changed_buffers, cx);
+
+ let is_running = tool_call_in_progress;
+
+ let card_header_id =
+ SharedString::from(format!("subagent-header-{}-{}", entry_ix, context_ix));
+ let card_id = SharedString::from(format!("subagent-card-{}-{}", entry_ix, context_ix));
+ let disclosure_id =
+ SharedString::from(format!("subagent-disclosure-{}-{}", entry_ix, context_ix));
+ let diff_stat_id = SharedString::from(format!("subagent-diff-{}-{}", entry_ix, context_ix));
+
+ v_flex()
+ .w_full()
+ .rounded_md()
+ .border_1()
+ .border_color(self.tool_card_border_color(cx))
+ .bg(cx.theme().colors().editor_background)
+ .overflow_hidden()
+ .child(
+ h_flex()
+ .id(card_id)
+ .group(&card_header_id)
+ .w_full()
+ .p_1()
+ .gap_1p5()
+ .bg(self.tool_card_header_bg(cx))
+ .child(
+ div()
+ .id(disclosure_id)
+ .cursor_pointer()
+ .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();
+ }
+ }))
+ .child(Disclosure::new(
+ SharedString::from(format!(
+ "subagent-disclosure-inner-{}-{}",
+ entry_ix, context_ix
+ )),
+ is_expanded,
+ )),
+ )
+ .child(if is_running {
+ SpinnerLabel::new()
+ .size(LabelSize::Small)
+ .into_any_element()
+ } else {
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success)
+ .into_any_element()
+ })
+ .child(
+ h_flex().flex_1().overflow_hidden().child(
+ Label::new(title.to_string())
+ .size(LabelSize::Small)
+ .color(Color::Default),
+ ),
+ )
+ .when(files_changed > 0, |this| {
+ this.child(
+ h_flex()
+ .gap_1()
+ .child(Label::new("—").size(LabelSize::Small).color(Color::Muted))
+ .child(
+ Label::new(format!(
+ "{} {} changed",
+ files_changed,
+ if files_changed == 1 { "file" } else { "files" }
+ ))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(DiffStat::new(
+ diff_stat_id.clone(),
+ diff_stats.lines_added as usize,
+ diff_stats.lines_removed as usize,
+ )),
+ )
+ }),
+ )
+ .when(is_expanded, |this| {
+ this.child(self.render_subagent_expanded_content(
+ entry_ix, context_ix, thread, is_running, window, cx,
+ ))
+ })
+ .into_any_element()
+ }
+
+ fn render_subagent_expanded_content(
+ &self,
+ _entry_ix: usize,
+ _context_ix: usize,
+ thread: &Entity<AcpThread>,
+ is_running: bool,
+ window: &Window,
+ cx: &Context<Self>,
+ ) -> impl IntoElement {
+ let thread_read = thread.read(cx);
+ let entries = thread_read.entries();
+
+ // Find the most recent assistant message with any content (message or thought)
+ let last_assistant_markdown = entries.iter().rev().find_map(|entry| {
+ if let AgentThreadEntry::AssistantMessage(msg) = entry {
+ msg.chunks.iter().find_map(|chunk| match chunk {
+ AssistantMessageChunk::Message { block } => block.markdown().cloned(),
+ AssistantMessageChunk::Thought { block } => block.markdown().cloned(),
+ })
+ } else {
+ None
+ }
+ });
+
+ let has_content = last_assistant_markdown.is_some();
+
+ v_flex()
+ .w_full()
+ .p_2()
+ .gap_2()
+ .border_t_1()
+ .border_color(self.tool_card_border_color(cx))
+ .bg(cx.theme().colors().editor_background)
+ .when_some(last_assistant_markdown, |this, markdown| {
+ this.child(
+ div()
+ .when(!is_running, |d| d.max_h(px(200.)).overflow_hidden())
+ .text_sm()
+ .child(self.render_markdown(
+ markdown,
+ default_markdown_style(false, false, window, cx),
+ )),
+ )
+ })
+ .when(is_running && !has_content, |this| {
+ this.child(SpinnerLabel::new().size(LabelSize::Small))
+ })
+ }
+
fn render_markdown_output(
&self,
markdown: Entity<Markdown>,
@@ -7361,6 +7564,13 @@ impl AcpThreadView {
self.expanded_tool_calls.insert(tool_call_id);
cx.notify();
}
+
+ /// Expands a subagent card so its content is visible.
+ /// This is primarily useful for visual testing.
+ pub fn expand_subagent(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
+ self.expanded_subagents.insert(session_id);
+ cx.notify();
+ }
}
impl Render for AcpThreadView {
@@ -350,14 +350,18 @@ impl ManageProfilesModal {
return;
};
+ //todo: This causes the web search tool to show up even it only works when using zed hosted models
+ let tool_names: Vec<Arc<str>> = agent::supported_built_in_tool_names(
+ self.active_model.as_ref().map(|model| model.provider_id()),
+ cx,
+ )
+ .into_iter()
+ .map(|s| Arc::from(s))
+ .collect();
+
let tool_picker = cx.new(|cx| {
let delegate = ToolPickerDelegate::builtin_tools(
- //todo: This causes the web search tool to show up even it only works when using zed hosted models
- agent::supported_built_in_tool_names(
- self.active_model.as_ref().map(|model| model.provider_id()),
- )
- .map(|s| s.into())
- .collect::<Vec<_>>(),
+ tool_names,
self.fs.clone(),
profile_id.clone(),
profile,
@@ -46,7 +46,7 @@ impl FeatureFlag for SubagentsFeatureFlag {
const NAME: &'static str = "subagents";
fn enabled_for_staff() -> bool {
- false
+ true
}
}
@@ -41,6 +41,7 @@ visual-tests = [
"image_viewer/test-support",
"clock/test-support",
"acp_thread/test-support",
+ "action_log/test-support",
"agent_ui/test-support",
"db/test-support",
"agent/test-support",
@@ -67,6 +67,7 @@ use {
sync::Arc,
time::Duration,
},
+ watch,
workspace::{AppState, Workspace},
};
@@ -403,24 +404,47 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
// Run Test 3: Agent Thread View tests
- println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
- match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
- Ok(TestResult::Passed) => {
- println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
- passed += 1;
- }
- Ok(TestResult::BaselineUpdated(_)) => {
- println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
- updated += 1;
+ #[cfg(feature = "visual-tests")]
+ {
+ println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
+ match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
+ Ok(TestResult::Passed) => {
+ println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
+ passed += 1;
+ }
+ Ok(TestResult::BaselineUpdated(_)) => {
+ println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
+ updated += 1;
+ }
+ Err(e) => {
+ eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
+ failed += 1;
+ }
}
- Err(e) => {
- eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
- failed += 1;
+ }
+
+ // 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 4: Breakpoint Hover visual tests
- println!("\n--- Test 4: breakpoint_hover (3 variants) ---");
+ // 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) {
Ok(TestResult::Passed) => {
println!("✓ breakpoint_hover: PASSED");
@@ -436,8 +460,8 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
- // Run Test 5: Diff Review Button visual tests
- println!("\n--- Test 5: diff_review_button (3 variants) ---");
+ // Run Test 6: Diff Review Button visual tests
+ println!("\n--- Test 6: diff_review_button (3 variants) ---");
match run_diff_review_visual_tests(app_state.clone(), &mut cx, update_baseline) {
Ok(TestResult::Passed) => {
println!("✓ diff_review_button: PASSED");
@@ -1645,7 +1669,327 @@ impl AgentServer for StubAgentServer {
}
}
-#[cfg(target_os = "macos")]
+#[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,
+ false,
+ 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)
+ });
+
+ let _ = cx.foreground_executor.block_test(add_worktree_task);
+
+ 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);
+ })
+ .ok();
+ })?;
+
+ 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).thread().cloned())
+ .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)
+ });
+
+ let _ = cx.foreground_executor.block_test(send_future);
+
+ 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,
+ )
+ .ok();
+ thread
+ .update_tool_call(
+ ToolCallUpdateSubagentThread {
+ id: tool_call_id.clone(),
+ thread: subagent2,
+ },
+ cx,
+ )
+ .ok();
+ });
+
+ 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,
+ )
+ .ok();
+ });
+
+ 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::AcpThreadView, 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);
+ }
+ });
+ })
+ .ok();
+
+ cx.run_until_parked();
+
+ let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
+ window.remove_window();
+ });
+
+ 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>,
cx: &mut VisualTestAppContext,