Render subagents in thread (#46188)

## Summary

This PR adds the UI for displaying subagent tool calls:

### Thread view changes
- Add `expanded_subagents` state HashMap for tracking expanded cards  
- Implement `render_subagent_tool_call()` for collapsed card with label
and chevron
- Add subagent detection via `is_subagent()` and `tool_name` field
- Handle `SubagentThread` content type in tool call rendering
- Add expand/collapse toggle button for subagent cards
- Style collapsed cards similar to terminal tool calls
- Support inline image rendering in content blocks

### Agent panel changes
- Add `open_external_thread_with_server()` for testing with stubbed
servers

### Test support
- Add `acp_thread/test-support` feature to agent_ui
- Add base64 dev dependency for image tests

Release Notes:

- N/A

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Richard FeldmanandAmp authored and GitHub committed 2026-01-14 13:49:51 -05:00
1 parent 41ee5a22ee
commit be110b3836
13 files changed
+1067 -211

No files matched your search

+2
View File
@@ -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,
},
+6 -1
View File
@@ -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));
}
+4
View File
@@ -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
+20 -1
View File
@@ -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)
+152 -64
View File
@@ -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,
+22 -5
View File
@@ -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(
+12 -2
View File
@@ -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,
+262 -110
View File
@@ -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();
let subagent_weak = subagent_thread.downgrade();
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(),
};
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,
)
});
// 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()
};
event_stream.update_subagent_thread(acp_thread.clone());
let label = config.label.clone();
let task_prompt = config.task_prompt.clone();
let timeout_ms = config.timeout_ms;
if let Some(parent) = parent_thread.upgrade() {
parent.update(cx, |thread, _cx| {
thread.register_running_subagent(subagent_weak.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(
&label,
connection,
project.clone(),
action_log,
session_id,
watch::Receiver::constant(acp::PromptCapabilities::new()),
cx,
)
});
event_stream.update_subagent_thread(acp_thread.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;
// Collect weak refs for cancellation cleanup
let subagent_threads: Vec<WeakEntity<Thread>> = subagent_data
.iter()
.map(|(_, thread, _, _, _)| thread.downgrade())
.collect();
if let Some(parent) = parent_thread.upgrade() {
let _ = parent.update(cx, |thread, _cx| {
thread.unregister_running_subagent(&subagent_weak);
});
// 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!(
properties.get("summary_prompt").is_some(),
"should have summary_prompt field"
item_properties.get("task_prompt").is_some(),
"subagent item should have task_prompt field"
);
assert!(
properties.get("context_low_prompt").is_some(),
"should have context_low_prompt field"
item_properties.get("summary_prompt").is_some(),
"subagent item should have summary_prompt field"
);
assert!(
properties.get("timeout_ms").is_some(),
"should have timeout_ms field"
item_properties.get("context_low_prompt").is_some(),
"subagent item should have context_low_prompt field"
);
assert!(
properties.get("allowed_tools").is_some(),
"should have allowed_tools field"
item_properties.get("timeout_ms").is_some(),
"subagent item should have timeout_ms field"
);
assert!(
item_properties.get("allowed_tools").is_some(),
"subagent item should have allowed_tools field"
);
}
+213 -3
View File
@@ -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,
+1 -1
View File
@@ -46,7 +46,7 @@ impl FeatureFlag for SubagentsFeatureFlag {
const NAME: &'static str = "subagents";
fn enabled_for_staff() -> bool {
false
true
}
}
+1
View File
@@ -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",
+362 -18
View File
@@ -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;
}
Err(e) => {
eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
failed += 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;
}
}
}
// Run Test 4: Breakpoint Hover visual tests
println!("\n--- Test 4: breakpoint_hover (3 variants) ---");
// 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) {
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,