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:
1 parent
41ee5a22ee
commit
be110b3836
13 files changed
+1067
-211
No files matched your search
@@ -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)
|
||||
|
||||
+152
-64
@@ -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();
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user