Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy

This commit is contained in:
Ryan Ward
2026-08-18 11:30:18 -05:00
297 changed files with 47942 additions and 13733 deletions
+33 -5
View File
@@ -1,4 +1,7 @@
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION};
use galaxy_acp::{
resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION,
OPENCODE_NPM_VERSION,
};
use sha2::{Digest as _, Sha256};
use crate::persistence::model::AcpConversationData;
@@ -7,6 +10,14 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String {
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
}
pub(crate) fn acp_provider_model_id(provider_id: &str, agent_id: &str) -> String {
format!(
"acp:{}:{}",
provider_id.trim().to_ascii_lowercase(),
agent_id.trim().to_ascii_lowercase()
)
}
pub(crate) fn acp_selection_model_id(
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
@@ -23,6 +34,21 @@ pub(crate) fn acp_selection_model_id(
}
}
pub(crate) fn acp_provider_selection_identity(
provider_id: &str,
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
) -> String {
let mut identity = acp_provider_model_id(provider_id, agent_id);
for (key, value) in values {
identity.push(':');
identity.push_str(key);
identity.push('=');
identity.push_str(&canonical_json_value(value));
}
identity
}
pub(crate) fn acp_selection_identity(
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
@@ -53,7 +79,7 @@ fn canonical_json_value(value: &serde_json::Value) -> String {
),
serde_json::Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
entries.sort_by_key(|(key, _)| *key);
format!(
"{{{}}}",
entries
@@ -215,9 +241,11 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
unknown => Err(format!(
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable"
)),
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!(
"{error} Configure a custom ACP executable if this client uses a different command."
)
}),
}
}
+3 -1
View File
@@ -4,7 +4,7 @@ use super::*;
fn unknown_builtin_agent_ids_are_rejected() {
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
assert!(error.contains("Unknown ACP agent preset"));
assert!(error.contains("Unknown ACP agent"));
}
#[test]
@@ -116,6 +116,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
let args = vec!["serve".to_owned()];
let launch = resolve_acp_launch("custom", command, &args).unwrap();
let backend = AcpConversationData {
provider_id: String::new(),
agent_id: "custom".to_owned(),
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
session_id: Some("session-123".to_owned()),
@@ -139,6 +140,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
#[test]
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
let backend = AcpConversationData {
provider_id: String::new(),
agent_id: "codex".to_owned(),
launch_fingerprint: String::new(),
session_id: Some("legacy-session".to_owned()),
+5 -5
View File
@@ -7,17 +7,17 @@
mod launch;
mod permissions;
mod prompt;
mod response_translator;
mod runtime_model;
mod transport;
pub(crate) use launch::{
acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id,
resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_identity,
acp_launch_fingerprint, acp_model_id, acp_provider_selection_identity, acp_selection_identity,
acp_selection_model_id, resolve_acp_launch, validate_acp_dispatch,
validate_acp_launch_identity,
};
pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpTurnControlSlot, GalaxyMcpTarget,
};
+39 -11
View File
@@ -1,4 +1,5 @@
use galaxy_acp::{ContentBlock, ImageContent, TextContent};
use base64::Engine as _;
use galaxy_agent_core::{ContentPart, MessageContent};
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
@@ -24,7 +25,7 @@ pub(super) struct GalaxyTerminalTools {
pub(super) fn prompt_content(
params: &RequestParams,
terminal_tools: GalaxyTerminalTools,
) -> Result<Vec<ContentBlock>, String> {
) -> Result<MessageContent, String> {
let visible_query = params
.input
.iter()
@@ -41,12 +42,15 @@ pub(super) fn prompt_content(
for item in context {
match item {
AIAgentContext::Image(image) => {
let mut file_name = image.file_name.clone();
params.redact_text_for_model(&mut file_name);
images.push(ContentBlock::Image(
ImageContent::new(image.data.clone(), image.mime_type.clone())
.uri(format!("attachment://{file_name}")),
));
let data = base64::engine::general_purpose::STANDARD
.decode(&image.data)
.map_err(|error| {
format!("failed to decode ACP image attachment: {error}")
})?;
images.push(ContentPart::Image {
data,
mime_type: image.mime_type.clone(),
});
}
AIAgentContext::SelectedText(text) => {
hidden_context.push(format!("Selected text:\n{text}"));
@@ -115,9 +119,14 @@ pub(super) fn prompt_content(
}
params.redact_text_for_model(&mut text);
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
prompt.extend(images);
Ok(prompt)
if images.is_empty() {
Ok(MessageContent::Text(text))
} else {
let mut parts = Vec::with_capacity(images.len() + 1);
parts.push(ContentPart::Text(text));
parts.extend(images);
Ok(MessageContent::MultiPart(parts))
}
}
fn append_hidden_input(
@@ -144,6 +153,25 @@ fn append_hidden_input(
));
}
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command,
..
} => {
hidden_context.push(format!(
"A monitored command has completed.\n\
Command: {}\n\
Galaxy block_id: {}\n\
Final output:\n{}\n\n{}",
completed_command.command,
completed_command.block_id,
tail_chars(
&completed_command.grid_contents,
MAX_RUNNING_COMMAND_OUTPUT_CHARS
),
prompt,
));
}
AIAgentInput::AutoCodeDiffQuery { query, .. } => {
hidden_context.push(format!(
"Galaxy system request: create a code diff.\n{query}"
+96 -50
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use galaxy_acp::ContentBlock;
use galaxy_agent_core::{ContentPart, MessageContent};
use regex::Regex;
use serial_test::serial;
@@ -42,6 +42,19 @@ fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
}
}
fn prompt_text(prompt: &MessageContent) -> &str {
match prompt {
MessageContent::Text(text) => text,
MessageContent::MultiPart(parts) => match &parts[0] {
ContentPart::Text(text) => text,
_ => panic!("expected prompt text first"),
},
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => {
panic!("expected user prompt")
}
}
}
#[test]
fn keeps_images_as_native_acp_content() {
let mut params = RequestParams::new_for_test();
@@ -56,17 +69,19 @@ fn keeps_images_as_native_acp_content() {
)];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
assert_eq!(prompt.len(), 2);
let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected multipart prompt");
};
assert_eq!(parts.len(), 2);
assert!(matches!(
&prompt[0],
ContentBlock::Text(text) if text.text == "What is in this image?"
&parts[0],
ContentPart::Text(text) if text == "What is in this image?"
));
assert!(matches!(
&prompt[1],
ContentBlock::Image(image)
if image.data == "aW1hZ2U="
&& image.mime_type == "image/png"
&& image.uri.as_deref() == Some("attachment://screen.png")
&parts[1],
ContentPart::Image { data, mime_type }
if data == b"image"
&& mime_type == "image/png"
));
}
@@ -80,13 +95,11 @@ fn sends_rules_and_selected_text_without_changing_visible_query() {
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Fix this"));
assert!(text.text.contains("hidden_from_transcript"));
assert!(text.text.contains("broken()"));
assert!(text.text.contains("Run tests first."));
let text = prompt_text(&prompt);
assert!(text.starts_with("Fix this"));
assert!(text.contains("hidden_from_transcript"));
assert!(text.contains("broken()"));
assert!(text.contains("Run tests first."));
}
#[test]
@@ -98,12 +111,50 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
}];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Handle the Galaxy system request"));
assert!(text.text.contains("Repair the failing unit test."));
assert!(text.text.contains("hidden_from_transcript"));
let text = prompt_text(&prompt);
assert!(text.starts_with("Handle the Galaxy system request"));
assert!(text.contains("Repair the failing unit test."));
assert!(text.contains("hidden_from_transcript"));
}
#[test]
fn completed_command_assessment_uses_hidden_context_without_monitor_guidance() {
let block_id = BlockId::from("completed-session-42".to_owned());
let mut params = RequestParams::new_for_test();
params.input = vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Report whether the command succeeded.".to_owned(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_owned())]),
completed_command: RunningCommand {
command: "script/run-soak-test".to_owned(),
block_id: block_id.clone(),
grid_contents: "completed successfully".to_owned(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}];
let prompt = prompt_content(
&params,
GalaxyTerminalTools {
status: true,
interrupt: true,
},
)
.expect("prompt");
let text = prompt_text(&prompt);
assert!(text.starts_with("Handle the Galaxy system request"));
assert!(text.contains("hidden_from_transcript"));
assert!(text.contains("A monitored command has completed."));
assert!(text.contains("script/run-soak-test"));
assert!(text.contains(block_id.as_str()));
assert!(text.contains("Final output:\ncompleted successfully"));
assert!(text.contains("Report whether the command succeeded."));
assert!(text.contains("Selected text:\nroot context"));
assert!(!text.contains("galaxy_terminal_status"));
assert!(!text.contains("galaxy_terminal_interrupt"));
assert!(!text.contains("running_for_ms"));
}
#[test]
@@ -135,16 +186,14 @@ fn running_command_identity_and_output_are_sent_as_hidden_context() {
},
)
.expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Stop this after 75 seconds."));
assert!(text.text.contains(block_id.as_str()));
assert!(text.text.contains("elapsed: 41s"));
assert!(text.text.contains("galaxy_terminal_status"));
assert!(text.text.contains("running_for_ms"));
assert!(text.text.contains("galaxy_terminal_interrupt_at"));
assert!(text.text.contains("outside the model loop"));
let text = prompt_text(&prompt);
assert!(text.starts_with("Stop this after 75 seconds."));
assert!(text.contains(block_id.as_str()));
assert!(text.contains("elapsed: 41s"));
assert!(text.contains("galaxy_terminal_status"));
assert!(text.contains("running_for_ms"));
assert!(text.contains("galaxy_terminal_interrupt_at"));
assert!(text.contains("outside the model loop"));
}
#[test]
@@ -176,12 +225,10 @@ fn running_command_prompt_does_not_advertise_unavailable_mutations() {
},
)
.expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.contains("galaxy_terminal_status"));
assert!(text.text.contains("no Galaxy terminal mutation tool"));
assert!(!text.text.contains("galaxy_terminal_interrupt_at"));
let text = prompt_text(&prompt);
assert!(text.contains("galaxy_terminal_status"));
assert!(text.contains("no Galaxy terminal mutation tool"));
assert!(!text.contains("galaxy_terminal_interrupt_at"));
}
#[test]
@@ -224,20 +271,19 @@ fn redacts_request_text_before_creating_acp_content_blocks() {
}];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected multipart prompt");
};
assert!(!text.text.contains(SECRET));
assert!(text.text.contains("******************"));
assert!(text.text.contains("Selected text:"));
assert!(text.text.contains("Current output:"));
assert!(text.text.contains("Attachment notes.txt:"));
assert!(text.text.contains("Galaxy rules:"));
let text = prompt_text(&prompt);
assert!(!text.contains(SECRET));
assert!(text.contains("******************"));
assert!(text.contains("Selected text:"));
assert!(text.contains("Current output:"));
assert!(text.contains("Attachment notes.txt:"));
assert!(text.contains("Galaxy rules:"));
assert!(matches!(
&prompt[1],
ContentBlock::Image(image)
if image.data == "aW1hZ2U="
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
&parts[1],
ContentPart::Image { data, .. } if data == b"image"
));
// Prompt redaction must not mutate the local transcript copy.
-335
View File
@@ -1,335 +0,0 @@
use std::collections::HashMap;
use galaxy_acp::{AcpEvent, ContentBlock, StopReason, ToolCallId, ToolCallStatus};
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ResponseEvent};
use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
};
/// Stateful translation from ACP session updates to Galaxy's existing agent UI
/// response protocol.
pub(super) struct AcpResponseTranslator {
task_id: String,
request_id: String,
needs_create_task: bool,
user_query: Option<String>,
model_id: String,
initialized: bool,
message_id: Option<String>,
tool_titles: HashMap<ToolCallId, String>,
used_tokens: u64,
context_size: u64,
accept_next_user_content: bool,
}
impl AcpResponseTranslator {
pub(super) fn new(
task_id: String,
needs_create_task: bool,
user_query: Option<String>,
model_id: String,
) -> Self {
Self {
task_id,
request_id: Uuid::new_v4().to_string(),
needs_create_task,
user_query,
model_id,
initialized: false,
message_id: None,
tool_titles: HashMap::new(),
used_tokens: 0,
context_size: 0,
accept_next_user_content: false,
}
}
pub(super) fn translate(&mut self, event: AcpEvent) -> Result<Vec<ResponseEvent>, String> {
let mut events = Vec::new();
match event {
AcpEvent::SessionStarted { .. } => self.initialize(&mut events),
AcpEvent::AgentText { text } => {
self.initialize(&mut events);
self.add_or_append(&text, &mut events);
}
// Reasoning is deliberately not copied into the plain assistant
// transcript. ACP agents can still expose plans and tool progress.
AcpEvent::AgentThought { .. } => {}
AcpEvent::AgentContent { content, thought } => {
if !thought {
self.initialize(&mut events);
let description = match content {
ContentBlock::Text(text) => text.text,
ContentBlock::Image(_) => "[Agent returned an image.]".to_owned(),
ContentBlock::Audio(_) => "[Agent returned audio.]".to_owned(),
ContentBlock::ResourceLink(resource) => {
format!("[Agent referenced {}.]", resource.name)
}
ContentBlock::Resource(_) => {
"[Agent returned embedded resource content.]".to_owned()
}
_ => "[Agent returned unsupported content.]".to_owned(),
};
self.add_or_append(&description, &mut events);
}
}
AcpEvent::UserContent { content } => {
// Some ACP adapters replay user-message chunks while loading a
// session or echo Galaxy's initial prompt, which also contains
// hidden context. Only content explicitly authorized by the
// live-steering path may enter the visible transcript.
if self.accept_next_user_content {
self.accept_next_user_content = false;
self.initialize(&mut events);
if let ContentBlock::Text(text) = content {
events.push(build_user_query_message(&self.task_id, &text.text));
// Assistant output after steering belongs in a new chat
// bubble, not the message that preceded the follow-up.
self.message_id = None;
}
}
}
AcpEvent::ToolCall {
id,
title,
status,
output,
} => {
self.initialize(&mut events);
self.tool_titles.insert(id, title.clone());
self.add_or_append(&tool_status_line(&title, status), &mut events);
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
}
AcpEvent::ToolCallUpdate {
id,
title,
status,
output,
} => {
self.initialize(&mut events);
let title = title
.or_else(|| self.tool_titles.get(&id).cloned())
.unwrap_or_else(|| "tool".to_owned());
self.tool_titles.insert(id, title.clone());
if let Some(status) = status {
self.add_or_append(&tool_status_line(&title, status), &mut events);
}
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
}
AcpEvent::Usage { used, size, .. } => {
self.used_tokens = used;
self.context_size = size;
}
AcpEvent::PermissionRequested { request } => {
self.initialize(&mut events);
self.add_or_append(
&format!(
"\n\n> Permission requested for: {}\n",
request.tool_call.fields.title.as_deref().unwrap_or("tool")
),
&mut events,
);
}
AcpEvent::PermissionResolved { decision, .. } => {
self.initialize(&mut events);
self.add_or_append(
&format!("\n\n> Permission decision: {decision:?}\n"),
&mut events,
);
}
AcpEvent::Finished { stop_reason } => {
self.initialize(&mut events);
if self.message_id.is_none() && stop_reason != StopReason::Cancelled {
self.add_or_append(
"> ACP agent completed without a text response.",
&mut events,
);
}
events.push(self.finished(stop_reason));
}
AcpEvent::Error { message } => return Err(message),
// ACP events are forward-compatible. Unknown events do not belong
// in the user-visible transcript until Galaxy knows their meaning.
_ => {}
}
Ok(events)
}
pub(super) fn translate_steered_user_content(
&mut self,
content: ContentBlock,
) -> Result<Vec<ResponseEvent>, String> {
self.accept_next_user_content = true;
self.translate(AcpEvent::UserContent { content })
}
pub(super) fn steering_failed(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
&format!(
"Galaxy couldn't confirm that live steering message: {error}. \
The agent may not have received it; check the current terminal and file state \
before retrying."
),
&mut events,
);
// Any output still arriving from the original turn should not be
// appended to Galaxy's steering-failure notice.
self.message_id = None;
events
}
pub(super) fn steering_started_new_turn(&mut self) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
"The ACP adapter started that steering message as a separate turn instead of \
injecting it into the active one. Galaxy terminated the adapter process immediately, \
but the turn may have begun acting; check the current terminal and file state before \
retrying.",
&mut events,
);
self.message_id = None;
events
}
pub(super) fn startup_error(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
&format!("Galaxy couldn't start the ACP agent: {error}"),
&mut events,
);
events.push(self.finished(StopReason::Refusal));
events
}
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
if self.initialized {
return;
}
// The ACP session ID is persisted separately. An empty conversation ID
// keeps this synthetic Init event out of Galaxy cloud token paths.
events.push(build_stream_init(&self.request_id, ""));
if self.needs_create_task {
events.push(build_create_task(&self.task_id));
}
if let Some(user_query) = &self.user_query {
events.push(build_user_query_message(&self.task_id, user_query));
}
self.initialized = true;
}
fn add_or_append(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
if text.is_empty() {
return;
}
if let Some(message_id) = &self.message_id {
events.push(build_append_text(&self.task_id, message_id, text));
} else {
let message_id = Uuid::new_v4().to_string();
events.push(build_add_agent_output_message(
&self.task_id,
&message_id,
text,
));
self.message_id = Some(message_id);
}
}
fn finished(&self, stop_reason: StopReason) -> ResponseEvent {
let reason = match stop_reason {
StopReason::EndTurn | StopReason::Cancelled => {
stream_finished::Reason::Done(stream_finished::Done {})
}
StopReason::MaxTokens | StopReason::MaxTurnRequests => {
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
}
StopReason::Refusal => stream_finished::Reason::Other(stream_finished::Other {}),
// ACP marks this enum non-exhaustive so newer agents can add stop reasons
// without breaking older clients.
_ => stream_finished::Reason::Other(stream_finished::Other {}),
};
let used_tokens = u32::try_from(self.used_tokens).unwrap_or(u32::MAX);
let context_usage = if self.context_size == 0 {
0.0
} else {
(self.used_tokens as f32 / self.context_size as f32).clamp(0.0, 1.0)
};
#[allow(deprecated)]
let usage_metadata = stream_finished::ConversationUsageMetadata {
context_window_usage: context_usage,
summarized: false,
credits_spent: 0.0,
platform_credits_spent: 0.0,
total_input_tokens: used_tokens,
token_usage: Vec::new(),
tool_usage_metadata: None,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
custom_endpoint_token_usage: HashMap::new(),
context_window_segments: Vec::new(),
};
ResponseEvent {
r#type: Some(api::response_event::Type::Finished(
api::response_event::StreamFinished {
reason: Some(reason),
token_usage: vec![stream_finished::TokenUsage {
model_id: self.model_id.clone(),
// ACP reports current context occupancy, not the input
// consumed by this individual request. Galaxy separately
// accumulates per-request token usage, so counting it
// here would grow the total again on every turn.
total_input: 0,
output: 0,
input_cache_read: 0,
input_cache_write: 0,
cost_in_cents: 0.0,
}],
should_refresh_model_config: false,
request_cost: None,
conversation_usage_metadata: Some(usage_metadata),
},
)),
}
}
}
fn tool_status_line(title: &str, status: ToolCallStatus) -> String {
let status = match status {
ToolCallStatus::Pending => "waiting",
ToolCallStatus::InProgress => "running",
ToolCallStatus::Completed => "completed",
ToolCallStatus::Failed => "failed",
// ACP marks this enum non-exhaustive. Preserve a useful transcript if a
// newer agent reports a status this client does not recognize yet.
_ => "updated",
};
format!("\n\n> **{title}** — {status}\n")
}
fn tool_output_block(output: &str) -> String {
let mut block = String::from("\n");
for line in output.lines() {
block.push_str(" ");
block.push_str(line);
block.push('\n');
}
block
}
#[cfg(test)]
#[path = "response_translator_tests.rs"]
mod tests;
-356
View File
@@ -1,356 +0,0 @@
use galaxy_acp::{
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
ToolCallStatus,
};
use warp_multi_agent_api::{client_action, message, response_event};
use super::AcpResponseTranslator;
#[test]
fn initializes_the_existing_chat_exchange_and_persists_user_text() {
let mut translator = AcpResponseTranslator::new(
"task".to_owned(),
true,
Some("hello".to_owned()),
"acp:codex".to_owned(),
);
let events = translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
.expect("translate");
assert!(matches!(
events[0].r#type,
Some(response_event::Type::Init(_))
));
assert!(matches!(
events[1].r#type,
Some(response_event::Type::ClientActions(_))
));
assert!(matches!(
events[2].r#type,
Some(response_event::Type::ClientActions(_))
));
}
#[test]
fn streams_agent_text_as_add_then_append() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let first = translator
.translate(AcpEvent::AgentText {
text: "one".to_owned(),
})
.expect("first");
let second = translator
.translate(AcpEvent::AgentText {
text: " two".to_owned(),
})
.expect("second");
let Some(response_event::Type::ClientActions(first_actions)) = &first[1].r#type else {
panic!("expected first client action");
};
assert!(matches!(
first_actions.actions[0].action,
Some(client_action::Action::AddMessagesToTask(_))
));
let Some(response_event::Type::ClientActions(second_actions)) = &second[0].r#type else {
panic!("expected append client action");
};
assert!(matches!(
second_actions.actions[0].action,
Some(client_action::Action::AppendToMessageContent(_))
));
}
#[test]
fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Read file".to_owned(),
status: ToolCallStatus::InProgress,
output: None,
})
.expect("tool");
let Some(response_event::Type::ClientActions(actions)) = &events[1].r#type else {
panic!("expected client action");
};
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
panic!("expected display-only message");
};
assert!(matches!(
add.messages[0].message,
Some(message::Message::AgentOutput(_))
));
}
#[test]
fn maps_usage_and_successful_completion() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::Usage {
used: 25,
size: 100,
cost: None,
})
.expect("usage");
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
})
.expect("finished");
let Some(finished) = events.iter().find_map(|event| {
let Some(response_event::Type::Finished(finished)) = &event.r#type else {
return None;
};
Some(finished)
}) else {
panic!("expected finished");
};
assert_eq!(finished.token_usage[0].total_input, 0);
assert_eq!(
finished
.conversation_usage_metadata
.as_ref()
.expect("metadata")
.context_window_usage,
0.25
);
}
#[test]
fn renders_bounded_tool_output_in_the_agent_transcript() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Run tests".to_owned(),
status: ToolCallStatus::Completed,
output: Some("test one ... ok\ntest two ... ok".to_owned()),
})
.expect("tool");
let Some(response_event::Type::ClientActions(status_actions)) = &events[1].r#type else {
panic!("expected status action");
};
let Some(client_action::Action::AddMessagesToTask(add_status)) =
&status_actions.actions[0].action
else {
panic!("expected status message");
};
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
panic!("expected agent output");
};
assert!(status.text.contains("Run tests"));
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
panic!("expected output action");
};
assert!(matches!(
output_actions.actions[0].action,
Some(client_action::Action::AppendToMessageContent(_))
));
}
#[test]
fn successful_turn_without_agent_output_is_still_visible() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
})
.expect("finished");
assert!(events.iter().any(|event| {
let Some(response_event::Type::ClientActions(actions)) = &event.r#type else {
return false;
};
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
return false;
};
let Some(message::Message::AgentOutput(output)) = &add.messages[0].message else {
return false;
};
output.text.contains("completed without a text response")
}));
}
#[test]
fn suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::UserContent {
content: ContentBlock::Text(TextContent::new(
"hidden initial prompt and system context",
)),
})
.expect("translate");
assert!(events.is_empty());
}
#[test]
fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::AgentText {
text: "original response".to_owned(),
})
.expect("initial output");
let steered = translator
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
.expect("steering");
let Some(response_event::Type::ClientActions(user_actions)) = &steered[0].r#type else {
panic!("expected user client action");
};
let Some(client_action::Action::AddMessagesToTask(add_user)) = &user_actions.actions[0].action
else {
panic!("expected user message");
};
assert!(matches!(
add_user.messages[0].message,
Some(message::Message::UserQuery(_))
));
let resumed = translator
.translate(AcpEvent::AgentText {
text: "steered response".to_owned(),
})
.expect("resumed output");
let Some(response_event::Type::ClientActions(agent_actions)) = &resumed[0].r#type else {
panic!("expected agent client action");
};
assert!(matches!(
agent_actions.actions[0].action,
Some(client_action::Action::AddMessagesToTask(_))
));
}
#[test]
fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
.expect("initialize");
let events = translator.steering_failed("turn is no longer active");
assert_eq!(events.len(), 1);
let Some(response_event::Type::ClientActions(error_actions)) = &events[0].r#type else {
panic!("expected visible error action");
};
let Some(client_action::Action::AddMessagesToTask(add_error)) =
&error_actions.actions[0].action
else {
panic!("expected visible error message");
};
let Some(message::Message::AgentOutput(output)) = &add_error.messages[0].message else {
panic!("expected agent output");
};
assert!(output.text.contains("couldn't confirm"));
assert!(output.text.contains("before retrying"));
}
#[test]
fn implicit_steering_turn_warning_does_not_recommend_a_blind_retry() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator.steering_started_new_turn();
let text =
events
.iter()
.filter_map(|event| match &event.r#type {
Some(response_event::Type::ClientActions(actions)) => actions
.actions
.iter()
.find_map(|action| match &action.action {
Some(client_action::Action::AddMessagesToTask(add)) => add
.messages
.iter()
.find_map(|message| match &message.message {
Some(message::Message::AgentOutput(output)) => {
Some(output.text.as_str())
}
_ => None,
}),
_ => None,
}),
_ => None,
})
.collect::<String>();
assert!(text.contains("started"));
assert!(text.contains("terminated"));
assert!(text.contains("immediately"));
assert!(text.contains("may have begun acting"));
}
#[test]
fn startup_error_keeps_the_user_request_and_finishes_visibly() {
let mut translator = AcpResponseTranslator::new(
"task".to_owned(),
false,
Some("help me".to_owned()),
"acp:codex".to_owned(),
);
let events = translator.startup_error("adapter missing");
assert_eq!(events.len(), 4);
assert!(matches!(
events[0].r#type,
Some(response_event::Type::Init(_))
));
let Some(response_event::Type::ClientActions(user_actions)) = &events[1].r#type else {
panic!("expected visible user request");
};
let Some(client_action::Action::AddMessagesToTask(user_messages)) =
&user_actions.actions[0].action
else {
panic!("expected user message");
};
assert!(matches!(
user_messages.messages[0].message,
Some(message::Message::UserQuery(_))
));
let Some(response_event::Type::ClientActions(error_actions)) = &events[2].r#type else {
panic!("expected visible startup error");
};
let Some(client_action::Action::AddMessagesToTask(error_messages)) =
&error_actions.actions[0].action
else {
panic!("expected error message");
};
let Some(message::Message::AgentOutput(output)) = &error_messages.messages[0].message else {
panic!("expected agent output");
};
assert!(output.text.contains("adapter missing"));
assert!(matches!(
events[3].r#type,
Some(response_event::Type::Finished(_))
));
}
+8 -15
View File
@@ -86,17 +86,12 @@ impl AcpRuntimeModel {
Ok(manager)
}
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> {
let agent_id = if settings.acp_agent_id.value().trim().is_empty() {
"codex"
} else {
settings.acp_agent_id.value().trim()
};
let launch = crate::ai::acp::resolve_acp_launch(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
)?;
pub(crate) fn discovery_config_for_values(
agent_id: &str,
command: &str,
args: &[String],
) -> Result<AcpManagerConfig, String> {
let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?;
Ok(AcpManagerConfig::new(launch))
}
@@ -164,10 +159,8 @@ impl AcpRuntimeModel {
) -> BTreeMap<String, serde_json::Value> {
options
.iter()
.filter_map(|option| {
(!option.current_value.is_null())
.then(|| (option.id.clone(), option.current_value.clone()))
})
.filter(|option| !option.current_value.is_null())
.map(|option| (option.id.clone(), option.current_value.clone()))
.collect()
}
+94 -175
View File
@@ -1,38 +1,27 @@
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use futures::channel::oneshot;
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
use futures::stream::FusedStream as _;
use futures::{FutureExt as _, StreamExt as _};
use galaxy_acp::{
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio,
SessionConfigOptionValue, SessionId, TextContent,
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpPermissionPolicy, AcpRuntimeState,
AcpRuntimeStateHandle, AcpSessionManager, McpServer, McpServerStdio, SessionConfigOptionValue,
SessionId,
};
use galaxy_agent_core::{
turn_control, AgentRuntime as _, RuntimeCapabilities, TurnCommand, TurnCommandSender,
TurnRequest,
};
use super::launch::acp_selection_identity;
use super::launch::{acp_provider_selection_identity, acp_selection_identity};
use super::prompt::{prompt_content, GalaxyTerminalTools};
use super::response_translator::AcpResponseTranslator;
use crate::ai::agent::api::{self, RequestParams};
use crate::ai::agent::EntrypointType;
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
use crate::persistence::model::AcpConversationData;
use crate::server::server_api::AIApiError;
#[derive(Clone, Debug, Default)]
pub(crate) struct AcpSessionMetadata {
pub(crate) session_id: Option<String>,
pub(crate) can_load: bool,
pub(crate) can_steer: bool,
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
}
#[derive(Clone, Debug)]
pub(crate) struct AcpSteeringRequest {
display_text: String,
model_text: String,
}
pub(crate) type AcpSessionMetadata = AcpRuntimeState;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GalaxyMcpTarget {
@@ -41,46 +30,25 @@ pub(crate) struct GalaxyMcpTarget {
pub(crate) pane_id: String,
}
impl AcpSteeringRequest {
pub(crate) fn text(display_text: String, model_text: String) -> Self {
Self {
display_text,
model_text,
}
}
pub(crate) type AcpTurnControlSlot = Arc<Mutex<Option<TurnCommandSender>>>;
struct AcpTurnControlGuard {
slot: AcpTurnControlSlot,
}
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
fn pending_steering(session: AcpSessionHandle, steering: AcpSteeringRequest) -> PendingSteering {
async move {
let content = ContentBlock::Text(TextContent::new(steering.model_text));
session.steer(vec![content]).await
}
.boxed()
.fuse()
}
pub(crate) type AcpSessionHandleSlot = Arc<Mutex<Option<AcpSessionHandle>>>;
struct AcpSessionHandleGuard {
slot: AcpSessionHandleSlot,
}
impl AcpSessionHandleGuard {
fn new(slot: AcpSessionHandleSlot, session: AcpSessionHandle) -> Self {
if let Ok(mut active_session) = slot.lock() {
*active_session = Some(session);
impl AcpTurnControlGuard {
fn new(slot: AcpTurnControlSlot, control: TurnCommandSender) -> Self {
if let Ok(mut active_control) = slot.lock() {
*active_control = Some(control);
}
Self { slot }
}
}
impl Drop for AcpSessionHandleGuard {
impl Drop for AcpTurnControlGuard {
fn drop(&mut self) {
if let Ok(mut active_session) = self.slot.lock() {
*active_session = None;
if let Ok(mut active_control) = self.slot.lock() {
*active_control = None;
}
}
}
@@ -95,9 +63,8 @@ pub(crate) async fn acp_output_stream(
galaxy_terminal_interrupt_available: bool,
permission_policy: AcpPermissionPolicy,
auto_approve_permissions: bool,
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
session_handle: AcpSessionHandleSlot,
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
session_metadata: AcpRuntimeStateHandle,
turn_control_slot: AcpTurnControlSlot,
cancellation_rx: oneshot::Receiver<()>,
) -> api::ResponseStream {
let mut translator = response_translator(&params, &backend);
@@ -121,136 +88,69 @@ pub(crate) async fn acp_output_stream(
if let Some(server) = galaxy_mcp_server {
mcp_servers.push(server);
}
let request = AcpTurnRequest {
config_values: backend
.config_values
.into_iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value)
.ok()
.map(|value| (key, value))
})
.collect(),
conversation_key: conversation_id,
session_id: backend.session_id.map(SessionId::from),
cwd,
additional_directories: Vec::new(),
prompt,
mcp_servers,
auto_approve_permissions,
permission_policy,
prompt_capabilities: Default::default(),
let runtime_id = if backend.provider_id.is_empty() {
acp_selection_identity(&backend.agent_id, &backend.config_values)
} else {
acp_provider_selection_identity(
&backend.provider_id,
&backend.agent_id,
&backend.config_values,
)
};
let (session, events) = match manager.run_turn(request) {
Ok(turn) => turn,
let mut runtime_config =
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
runtime_config.config_values = backend
.config_values
.iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value.clone())
.ok()
.map(|value| (key.clone(), value))
})
.collect();
runtime_config.session_id = backend.session_id.clone().map(SessionId::from);
runtime_config.mcp_servers = mcp_servers;
runtime_config.auto_approve_permissions = auto_approve_permissions;
runtime_config.permission_policy = permission_policy;
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt);
request.conversation_id = Some(conversation_id);
let (control_sender, control) = turn_control();
let events = match runtime.start_turn(request, control).await {
Ok(events) => events,
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
};
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone());
let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
let stream = async_stream::stream! {
let _session_handle_guard = session_handle_guard;
let _turn_control_guard = turn_control_guard;
let mut cancellation_rx = cancellation_rx.fuse();
let mut events = Box::pin(events.fuse());
let mut steering_rx = Box::pin(steering_rx.fuse());
let mut steering_queue = VecDeque::new();
let mut steering_result: PendingSteering = Fuse::terminated();
let mut events = events.fuse();
loop {
futures::select_biased! {
_ = cancellation_rx => {
if let Err(error) = session.cancel().await {
log::warn!("Failed to cancel ACP turn cleanly: {error}");
}
break;
}
steering = steering_rx.next() => {
let Some(steering) = steering else {
continue;
};
let content = ContentBlock::Text(TextContent::new(
steering.display_text.clone(),
));
match translator.translate_steered_user_content(content) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(response_event);
}
}
Err(message) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(message),
}));
break;
}
}
if steering_result.is_terminated() {
steering_result = pending_steering(session.clone(), steering);
} else {
steering_queue.push_back(steering);
}
}
steering = steering_result => {
match steering {
Ok(AcpSteeringOutcome::Injected) => {
// The user message was rendered as soon as Galaxy
// accepted it; keep consuming agent events without
// holding the transcript behind the steering RPC.
}
Ok(AcpSteeringOutcome::StartedNewTurn) => {
for response_event in translator.steering_started_new_turn() {
yield Ok(response_event);
}
}
Ok(AcpSteeringOutcome::Failed) => {
for response_event in translator.steering_failed(
"the ACP agent could not inject it into the active turn",
) {
yield Ok(response_event);
}
}
Err(error) => {
log::warn!("ACP live steering failed: {error}");
for response_event in translator.steering_failed(&error.to_string()) {
yield Ok(response_event);
}
}
}
steering_result = Fuse::terminated();
if let Some(steering) = steering_queue.pop_front() {
steering_result = pending_steering(session.clone(), steering);
} else if events.is_terminated() {
break;
if let Err(error) = control_sender.try_send(TurnCommand::Cancel) {
log::warn!("Failed to queue ACP cancellation: {error}");
}
}
event = events.next() => {
let Some(event) = event else {
if steering_result.is_terminated() && steering_queue.is_empty() {
break;
};
let event = match event {
Ok(event) => event,
Err(error) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(error),
}));
break;
}
continue;
};
if let AcpEvent::SessionStarted {
session_id,
can_load,
can_steer,
..
} = &event
{
if let Ok(mut metadata) = session_metadata.lock() {
metadata.session_id = Some(session_id.to_string());
metadata.can_load = *can_load;
metadata.can_steer = *can_steer;
}
}
if let AcpEvent::ConfigOptions { options } = &event {
if let Ok(mut metadata) = session_metadata.lock() {
metadata.config_options = options.clone();
}
}
match translator.translate(event) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(response_event);
yield Ok(api::StreamEvent::Response(response_event));
}
}
Err(message) => {
@@ -279,18 +179,32 @@ pub(crate) fn acp_startup_error_stream(
fn response_translator(
params: &RequestParams,
backend: &AcpConversationData,
) -> AcpResponseTranslator {
) -> RuntimeResponseTranslator {
let task_id = params
.root_task_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let user_query = request_user_query(params);
AcpResponseTranslator::new(
RuntimeResponseTranslator::new(RuntimeResponseConfig {
task_id,
params.tasks.is_empty(),
// ACP owns its session identifier. Keeping this empty prevents the
// compatibility Init event from entering Galaxy cloud-token paths.
conversation_id: String::new(),
needs_create_task: params.tasks.is_empty(),
user_query,
acp_selection_identity(&backend.agent_id, &backend.config_values),
)
model_id: if backend.provider_id.is_empty() {
acp_selection_identity(&backend.agent_id, &backend.config_values)
} else {
acp_provider_selection_identity(
&backend.provider_id,
&backend.agent_id,
&backend.config_values,
)
},
max_context_tokens: None,
capabilities: RuntimeCapabilities::session_runtime(),
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
})
}
fn request_user_query(params: &RequestParams) -> Option<String> {
@@ -359,11 +273,16 @@ fn galaxy_mcp_args(
}
fn translated_startup_error_stream(
mut translator: AcpResponseTranslator,
mut translator: RuntimeResponseTranslator,
message: &str,
) -> api::ResponseStream {
let events = translator.startup_error(message);
Box::pin(futures::stream::iter(events.into_iter().map(Ok)))
let events =
translator.startup_error(&format!("Galaxy couldn't start the ACP agent: {message}"));
Box::pin(futures::stream::iter(
events
.into_iter()
.map(|event| Ok(api::StreamEvent::Response(event))),
))
}
#[cfg(test)]
+56 -25
View File
@@ -14,13 +14,14 @@ pub use convert_from::{
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
};
use futures_lite::Stream;
use galaxy_agent_core::ToolResult;
use galaxy_core::channel::ChannelState;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output;
pub(crate) use r#impl::prepare_direct_provider_params;
use serde::Serialize;
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
@@ -36,6 +37,21 @@ use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces;
const INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA: &str =
"galaxy:internal-command-completion-assessment:v1";
pub(crate) fn mark_internal_command_completion_assessment(
message: &mut warp_multi_agent_api::Message,
) {
message.server_message_data = INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA.to_string();
}
pub(crate) fn is_internal_command_completion_assessment(
message: &warp_multi_agent_api::Message,
) -> bool {
message.server_message_data == INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA
}
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
@@ -96,6 +112,8 @@ pub struct RequestParams {
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
pub terminal_view_id: Option<EntityId>,
pub input: Vec<AIAgentInput>,
/// Normalized action results appended to direct-provider run history.
pub tool_results: Vec<ToolResult>,
pub conversation_token: Option<ServerConversationToken>,
pub forked_from_conversation_token: Option<ServerConversationToken>,
pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
@@ -133,44 +151,55 @@ pub struct RequestParams {
pub research_agent_enabled: bool,
pub orchestration_enabled: bool,
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
/// The root task ID for the conversation — needed for direct Bedrock streaming
/// since optimistic tasks don't appear in the proto task_context.
/// The root task ID used to anchor direct-provider projection when optimistic tasks are not
/// present in the proto task context.
pub root_task_id: Option<String>,
/// The conversation ID of the parent agent that spawned this child agent, if any.
pub parent_agent_id: Option<String>,
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
pub agent_name: Option<String>,
/// Full Bedrock conversation history for direct Bedrock calls.
/// When present, the Bedrock path uses this instead of extracting from task_context.
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Provider-neutral conversation history for direct model calls.
pub message_history: Vec<crate::ai::provider::types::ConversationMessage>,
/// Progressive summary of older conversation history. Prepended as the first
/// message pair in the messages array sent to Bedrock.
pub bedrock_progressive_summary: Option<String>,
/// message pair in the messages array sent to the model.
pub progressive_summary: Option<String>,
/// Archived tool_use/tool_result pairs from previous summarization drains.
/// Passed to the Bedrock translator so `recall_tool_history` can search archived
/// results even after they've been summarized away from live history.
pub bedrock_tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Populated by the Bedrock path after building the message list.
/// Contains the full messages sent (old history + new input) so the controller
/// can store them back into the conversation for the next request cycle.
pub bedrock_messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
/// Kept separately so `recall_tool_history` can search archived results even after
/// they've been summarized away from live history.
pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>,
/// Populated while preparing a direct-provider run with the durable transcript that the
/// controller persists for restoration and future turns.
pub messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
/// Injected into the system prompt when `is_memory_enabled` is true.
pub global_rules: Vec<(String, String)>,
}
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
/// Response event projected into the local conversation controller.
#[derive(Debug)]
pub enum StreamEvent {
Response(warp_multi_agent_api::ResponseEvent),
}
pub type Event = Result<StreamEvent, Arc<AIApiError>>;
pub type LegacyEvent = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
#[cfg(not(target_family = "wasm"))]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>;
#[cfg(not(target_family = "wasm"))]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent> + Send + 'static>>;
// The WASM version of this type has no bound on `Send`, which is an unnecessary bound when
// targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async
// execution in WoW).
#[cfg(target_family = "wasm")]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>;
#[cfg(target_family = "wasm")]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent>>>;
#[derive(Debug, Clone)]
pub struct ConversationData {
pub id: AIConversationId,
@@ -187,6 +216,7 @@ impl RequestParams {
Self {
terminal_view_id: None,
input: vec![],
tool_results: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
@@ -218,10 +248,10 @@ impl RequestParams {
parent_agent_id: None,
agent_name: None,
root_task_id: None,
bedrock_message_history: vec![],
bedrock_progressive_summary: None,
bedrock_tool_result_archive: vec![],
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
message_history: vec![],
progressive_summary: None,
tool_result_archive: vec![],
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
global_rules: vec![],
}
}
@@ -391,6 +421,7 @@ impl RequestParams {
Self {
terminal_view_id,
input: request_input.all_inputs().cloned().collect(),
tool_results: Vec::new(),
conversation_token: conversation.server_conversation_token,
forked_from_conversation_token: conversation.forked_from_conversation_token,
ambient_agent_task_id: conversation.ambient_agent_task_id,
@@ -426,10 +457,10 @@ impl RequestParams {
.map(|id| id.to_string()),
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
message_history: Vec::new(),
progressive_summary: None,
tool_result_archive: Vec::new(),
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: if is_memory_enabled {
Self::load_global_rules(app)
} else {
+12 -5
View File
@@ -23,6 +23,7 @@ use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
};
@@ -72,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
let agent_conversation_data = match restoration_mode {
RestorationMode::Fork => AgentConversationData {
agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: usage_metadata,
reverted_action_ids: None,
@@ -95,6 +97,7 @@ pub fn convert_conversation_data_to_ai_conversation(
},
RestorationMode::Continue => AgentConversationData {
agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: Some(
metadata.server_conversation_token.as_str().to_string(),
),
@@ -389,17 +392,21 @@ impl ConvertToExchanges for &api::Task {
let added_message_as_exchange_input = match message {
api::message::Message::UserQuery(user_query) => {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
if is_internal_command_completion_assessment(api_message) {
false
} else {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
query: user_query.query.clone(),
context: convert_input_context(user_query.context.as_ref()),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
running_command: None,
intended_agent: Some(user_query.intended_agent()),
});
true
intended_agent: Some(user_query.intended_agent()),
});
true
}
}
api::message::Message::SystemQuery(query) => {
let Some(query_type) = &query.r#type else {
@@ -4,7 +4,7 @@ use chrono::Utc;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::api::{mark_internal_command_completion_assessment, ServerConversationToken};
use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
};
@@ -2129,6 +2129,61 @@ fn test_create_then_edit_then_create_version_tracking() {
);
}
#[test]
fn test_internal_command_completion_assessment_restores_output_without_visible_input() {
let assessment_text =
"[Completed command: cargo test]\n[Final terminal output:\ntest result: ok\n]";
let mut hidden_assessment = api::Message {
id: "msg_assessment".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: assessment_text.to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let provider_history =
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
.expect("hidden assessment should remain in provider history");
assert_eq!(
provider_history.role,
crate::ai::provider::types::MessageRole::User
);
assert!(matches!(
provider_history.content,
crate::ai::provider::types::MessageContent::Text(text) if text == assessment_text
));
let task = api::Task {
id: "task1".to_string(),
messages: vec![
hidden_assessment,
api::Message {
id: "msg_output".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "The command completed successfully.".to_string(),
},
)),
..Default::default()
},
],
..Default::default()
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1);
assert!(exchanges[0].input.is_empty());
assert_eq!(
exchanges[0].format_output_for_copy(None),
"The command completed successfully."
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs.
+22 -9
View File
@@ -16,16 +16,18 @@ use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
UserQueryMode, WebFetchStatus, WebSearchStatus,
};
use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -272,10 +274,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
match message {
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message(
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
.with_citations(citations),
)),
api::message::Message::AgentOutput(output) => {
let message = if let Some(activity) =
runtime_activity::decode(&self.server_message_data)
{
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
} else {
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
};
Ok(MaybeAIAgentOutputMessage::Message(
message.with_citations(citations),
))
}
api::message::Message::AgentReasoning(reasoning) => {
let duration = reasoning
.finished_duration
@@ -948,6 +958,9 @@ pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput>
let Some(inner) = &m.message else { continue };
match inner {
api::message::Message::UserQuery(uq) => {
if is_internal_command_completion_assessment(m) {
continue;
}
let context = convert_input_context(uq.context.as_ref());
let referenced_attachments = uq
.referenced_attachments
+78 -3
View File
@@ -2,16 +2,19 @@ use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
convert_api_question, user_inputs_from_messages, ConversionParams,
ConvertAPIMessageToClientOutputMessage, MaybeAIAgentOutputMessage,
};
use crate::ai::agent::api::mark_internal_command_completion_assessment;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
runtime_activity, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
LifecycleEventType, StartAgentExecutionMode,
};
fn start_agent_tool_call_message(
@@ -615,6 +618,36 @@ fn converts_local_start_agent_v2_with_harness_type() {
assert_eq!(lifecycle_subscription, None);
}
#[test]
fn internal_command_completion_assessment_is_not_restored_as_shared_user_input() {
let mut hidden_assessment = api::Message {
id: "hidden-assessment".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "[Completed command: cargo test]".to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let visible_query = api::Message {
id: "visible-query".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "What changed?".to_string(),
..Default::default()
})),
..Default::default()
};
let inputs = user_inputs_from_messages(&[hidden_assessment, visible_query]);
assert_eq!(inputs.len(), 1);
assert!(matches!(
&inputs[0],
AIAgentInput::UserQuery { query, .. } if query == "What changed?"
));
}
#[test]
fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string());
@@ -665,3 +698,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
}
}
}
#[test]
fn structured_runtime_activity_converts_to_display_only_output() {
let activity = RuntimeActivity {
id: "tool-1".to_owned(),
title: "List directories".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("payments\nrecords_v2".to_owned()),
};
let task_id = TaskId::new("task".to_owned());
let message = api::Message {
fetched_memories: Vec::new(),
id: "message".to_owned(),
task_id: "task".to_owned(),
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
citations: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "fallback text".to_owned(),
},
)),
request_id: "request".to_owned(),
timestamp: None,
};
let converted = message
.to_client_output_message(ConversionParams {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion");
let MaybeAIAgentOutputMessage::Message(output) = converted else {
panic!("expected display output");
};
assert_eq!(
output.message,
AIAgentOutputMessageType::RuntimeActivity(activity)
);
}
+37
View File
@@ -348,6 +348,43 @@ fn convert_input_to_user_input(
}
))
}
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command:
RunningCommand {
command,
block_id,
grid_contents: output,
cursor,
requested_command_id,
is_alt_screen_active,
},
..
} => Ok(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: prompt,
referenced_attachments: Default::default(),
mode: Some(UserQueryMode::Normal.into()),
intended_agent: api::AgentType::Primary.into(),
}),
running_command: Some(api::RunningShellCommand {
command,
snapshot: Some(api::LongRunningShellCommandSnapshot {
output,
cursor,
command_id: block_id.as_str().to_owned(),
is_alt_screen_active,
is_preempted: false,
}),
}),
run_shell_command_tool_call_id: requested_command_id
.map(|id| id.to_string())
.unwrap_or_default(),
},
),
),
AIAgentInput::ActionResult { result, .. } => result.try_into(),
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
+82 -2
View File
@@ -1,11 +1,13 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, ImageContext,
TransferShellCommandControlToUserResult,
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, ImageContext,
RunningCommand, TransferShellCommandControlToUserResult, UserQueryMode,
};
use crate::terminal::model::block::BlockId;
@@ -132,6 +134,84 @@ fn git_context_deserializes_legacy_string_pull_request_number() {
assert_eq!(pull_request.number, 42);
}
#[test]
fn command_completion_assessment_converts_to_primary_cli_query() {
let block_id = BlockId::from("completed-block".to_string());
let converted = super::convert_input(vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Summarize whether the command succeeded.".to_string(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_string())]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: "cursor".to_string(),
requested_command_id: Some("run-call".to_string().into()),
is_alt_screen_active: true,
},
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = converted.r#type else {
panic!("expected user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected CLI agent query");
};
let user_query = query
.user_query
.as_ref()
.expect("expected assessment prompt");
assert_eq!(user_query.query, "Summarize whether the command succeeded.");
assert_eq!(user_query.intended_agent(), api::AgentType::Primary);
let command = query
.running_command
.as_ref()
.expect("expected completed command");
assert_eq!(command.command, "cargo test -p galaxy");
let snapshot = command.snapshot.as_ref().expect("expected final snapshot");
assert_eq!(snapshot.command_id, block_id.as_str());
assert_eq!(snapshot.output, "test result: ok");
assert_eq!(snapshot.cursor, "cursor");
assert!(snapshot.is_alt_screen_active);
assert_eq!(query.run_shell_command_tool_call_id, "run-call");
let active = super::convert_input(vec![AIAgentInput::UserQuery {
query: "Keep monitoring.".to_string(),
context: Arc::from([]),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id,
grid_contents: "still running".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
}),
intended_agent: None,
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = active.r#type else {
panic!("expected active user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected active CLI agent query");
};
assert_eq!(
query
.user_query
.as_ref()
.expect("expected active monitor prompt")
.intended_agent(),
api::AgentType::Cli
);
}
#[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
+33 -204
View File
@@ -1,217 +1,43 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
use super::RequestParams;
use crate::ai::agent::redaction;
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use crate::server::server_api::AIApiError;
use crate::terminal::model::session::SessionType;
pub async fn generate_multi_agent_output(
provider_config: ProviderConfig,
mut params: RequestParams,
cancellation_rx: futures::channel::oneshot::Receiver<()>,
) -> Result<ResponseStream, ConvertToAPITypeError> {
let supported_tools_override = params.supported_tools_override.take();
let supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(&params));
let supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(&params));
let mut logging_metadata = HashMap::new();
if let Some(metadata) = params.metadata {
logging_metadata.insert(
"is_autodetected_user_query".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_autodetected_user_query,
)),
},
);
logging_metadata.insert(
"entrypoint".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue(
metadata.entrypoint.entrypoint(),
)),
},
);
logging_metadata.insert(
"is_auto_resume_after_error".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_auto_resume_after_error,
)),
},
);
fn remove_orchestration_tools_if_disabled(
supported_tools: &mut Vec<api::ToolType>,
orchestration_enabled: bool,
) {
if orchestration_enabled {
return;
}
supported_tools.retain(|tool| {
!matches!(
tool,
api::ToolType::Subagent | api::ToolType::RunAgents | api::ToolType::StartAgentV2
)
});
}
pub(crate) fn prepare_direct_provider_params(
params: &mut RequestParams,
) -> (Vec<api::ToolType>, Vec<api::ToolType>) {
let supported_tools_override = params.supported_tools_override.take();
let mut supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(params));
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools,
params.orchestration_enabled,
);
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
}),
input: Some(convert_input(params.input)?),
settings: Some(api::request::Settings {
model_config: Some(api::request::settings::ModelConfig {
base: params.model.clone().into(),
cli_agent: params.cli_agent_model.clone().into(),
computer_use_agent: params.computer_use_model.clone().into(),
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
..Default::default()
}),
rules_enabled: params.is_memory_enabled,
warp_drive_context_enabled: params.warp_drive_context_enabled,
web_context_retrieval_enabled: true,
supports_parallel_tool_calls: true,
use_anthropic_text_editor_tools: false,
planning_enabled: params.planning_enabled,
supports_create_files: true,
supported_tools: supported_tools.into_iter().map(Into::into).collect(),
supports_long_running_commands: true,
should_preserve_file_content_in_history: true,
supports_todos_ui: true,
supports_linked_code_blocks: FeatureFlag::LinkedCodeBlocks.is_enabled(),
supports_started_child_task_message: true,
// Galaxy's direct providers only receive tools with local schemas and
// executors. Hosted-only suggestion/orchestration capability bits must
// remain false so models do not plan around unavailable Warp services.
supports_suggest_prompt: false,
supports_read_image_files: FeatureFlag::ReadImageFiles.is_enabled(),
supports_reasoning_message: true,
api_keys: params.api_keys,
autonomy_level: params.autonomy_level.into(),
isolation_level: params.isolation_level.into(),
web_search_enabled: params.web_search_enabled,
supported_cli_agent_tools: supported_cli_agent_tools
.into_iter()
.map(Into::into)
.collect(),
supports_v4a_file_diffs: FeatureFlag::V4AFileDiffs.is_enabled(),
supports_summarization_via_message_replacement:
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
supports_research_agent: params.research_agent_enabled,
supports_orchestration_v2: false,
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
&& computer_use::background_supported(),
custom_model_providers: params.custom_model_providers,
custom_model_routers: params.custom_model_routers,
}),
metadata: Some(api::request::Metadata {
logging: logging_metadata,
conversation_id: params
.conversation_token
.as_ref()
.map(|token| token.as_str().to_string())
.unwrap_or_default(),
ambient_agent_task_id: params
.ambient_agent_task_id
.map(|id| id.to_string())
.unwrap_or_default(),
forked_from_conversation_id: if params.conversation_token.is_none() {
// We only include this param on our initial request to the server
// (when the forked conversation has not been assigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
.unwrap_or_default()
} else {
String::new()
},
parent_agent_id: params.parent_agent_id.unwrap_or_default(),
agent_name: params.agent_name.unwrap_or_default(),
}),
existing_suggestions: params
.existing_suggestions
.map(|suggestions| suggestions.into()),
mcp_context: params.mcp_context.map(Into::into),
};
match provider_config {
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
message_history: params.bedrock_message_history.clone(),
tool_result_archive: params.bedrock_tool_result_archive.clone(),
progressive_summary: params.bedrock_progressive_summary.clone(),
messages_sent: params.bedrock_messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
match openai_translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[openai] Translator error: {e}");
let err = Arc::new(
crate::server::server_api::AIApiError::Stream {
stream_type: "openai_chat_completions",
source: anyhow::anyhow!("{e}"),
}
.into_quota_limit_if_provider_budget_exhausted(),
);
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::Bedrock(config) => {
let translator_request = crate::ai::bedrock::translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::None => {
// No provider configured — do not fall back to Warp's cloud API.
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "none",
source: anyhow::anyhow!(
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
(supported_tools, supported_cli_agent_tools)
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
@@ -222,7 +48,6 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::ReadMcpResource,
api::ToolType::CallMcpTool,
api::ToolType::RunShellCommand,
api::ToolType::Subagent,
api::ToolType::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput,
api::ToolType::ReadDocuments,
@@ -230,6 +55,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::EditDocuments,
];
if params.orchestration_enabled {
supported_tools.push(api::ToolType::Subagent);
}
if FeatureFlag::ConversationsAsContext.is_enabled() {
supported_tools.push(api::ToolType::FetchConversation);
}
+52 -5
View File
@@ -2,7 +2,9 @@ use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId;
use warp_multi_agent_api as api;
use super::{get_supported_cli_agent_tools, get_supported_tools};
use super::{
get_supported_cli_agent_tools, get_supported_tools, remove_orchestration_tools_if_disabled,
};
use crate::ai::agent::api::RequestParams;
use crate::ai::blocklist::SessionContext;
use crate::ai::llms::LLMId;
@@ -14,6 +16,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
RequestParams {
terminal_view_id: None,
input: vec![],
tool_results: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
@@ -45,10 +48,10 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
root_task_id: None,
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
message_history: Vec::new(),
progressive_summary: None,
tool_result_archive: Vec::new(),
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: Vec::new(),
}
}
@@ -76,6 +79,50 @@ fn supported_tools_expose_local_subagents_without_hosted_orchestration_tools() {
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_include_plan_document_capabilities() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(supported_tools.contains(&api::ToolType::ReadDocuments));
assert!(supported_tools.contains(&api::ToolType::CreateDocuments));
assert!(supported_tools.contains(&api::ToolType::EditDocuments));
}
#[test]
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::Subagent));
}
#[test]
fn supported_tool_override_cannot_restore_leaf_orchestration_tools() {
let mut supported_tools = vec![
api::ToolType::Grep,
api::ToolType::Subagent,
api::ToolType::RunAgents,
api::ToolType::StartAgentV2,
];
remove_orchestration_tools_if_disabled(&mut supported_tools, false);
assert_eq!(supported_tools, vec![api::ToolType::Grep]);
}
#[test]
fn enabled_orchestration_preserves_supported_tool_override() {
let mut supported_tools = vec![api::ToolType::Grep, api::ToolType::Subagent];
remove_orchestration_tools_if_disabled(&mut supported_tools, true);
assert_eq!(
supported_tools,
vec![api::ToolType::Grep, api::ToolType::Subagent]
);
}
#[test]
fn supported_tools_omit_hosted_only_capabilities() {
let params = request_params_with_ask_user_question_enabled(false);
+227 -11
View File
@@ -229,6 +229,9 @@ pub struct AIConversation {
/// Runtime responsible for executing this conversation.
agent_backend: AgentBackend,
/// Opaque, versioned snapshot of the active direct-provider run.
active_provider_run_json: Option<String>,
/// The server-generated unique "token" for this conversation.
///
/// This must be roundtripped to the server when sending follow-ups within a given conversation.
@@ -380,6 +383,7 @@ impl AIConversation {
has_opened_code_review: false,
conversation_usage_metadata: ConversationUsageMetadata::default(),
agent_backend,
active_provider_run_json: None,
server_conversation_token: None,
task_id: None,
forked_from_server_conversation_token: None,
@@ -539,6 +543,7 @@ impl AIConversation {
let (
agent_backend,
active_provider_run_json,
server_conversation_token,
forked_from_server_conversation_token,
conversation_usage_metadata,
@@ -589,6 +594,7 @@ impl AIConversation {
};
(
data.agent_backend,
data.active_provider_run_json,
server_conversation_token,
forked_from_server_conversation_token,
conversation_usage_metadata,
@@ -611,6 +617,7 @@ impl AIConversation {
AgentBackend::default(),
None,
None,
None,
ConversationUsageMetadata::default(),
HashSet::new(),
Vec::new(),
@@ -663,6 +670,7 @@ impl AIConversation {
has_opened_code_review: false,
conversation_usage_metadata,
agent_backend,
active_provider_run_json,
server_conversation_token,
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
forked_from_server_conversation_token,
@@ -705,6 +713,35 @@ impl AIConversation {
&self.agent_backend
}
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
self.active_provider_run_json.as_deref()
}
pub(crate) fn set_active_provider_run_json(&mut self, snapshot: Option<String>) {
self.active_provider_run_json = snapshot;
}
/// Updates the backend of a conversation that has not produced agent output.
///
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
/// any exchange has produced output, the backend remains stable so provider-native and
/// ACP-owned histories are never mixed.
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
matches!(
&exchange.output_status,
AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
}
)
});
if !can_change_backend && self.agent_backend != agent_backend {
return false;
}
self.agent_backend = agent_backend;
true
}
/// Records a resumable ACP session ID.
///
/// Returns `false` when called for a native provider conversation.
@@ -1893,7 +1930,8 @@ impl AIConversation {
) -> String {
let mut result = Vec::new();
for exchange in self.all_exchanges() {
let formatted_exchange = exchange.format_for_copy(action_model);
let formatted_exchange =
exchange.format_for_copy_for_conversation(action_model, Some(self.id()));
if !formatted_exchange.is_empty() {
result.push(formatted_exchange);
}
@@ -1971,21 +2009,25 @@ impl AIConversation {
.sum()
}
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
self.task_store.tasks().any(|task| {
task.exchanges()
.any(|exchange| {
let Some(output) = exchange.output_status.output()
else {
return false;
};
output.get().messages.iter().any(|step| {
matches!(step, AIAgentOutputMessage{ message: AIAgentOutputMessageType::Action(AIAgentAction { id, .. }), .. } if id == action_id)
pub fn action(&self, action_id: &AIAgentActionId) -> Option<AIAgentAction> {
self.task_store.tasks().find_map(|task| {
task.exchanges().find_map(|exchange| {
let output = exchange.output_status.output()?;
output.get().messages.iter().find_map(|step| match step {
AIAgentOutputMessage {
message: AIAgentOutputMessageType::Action(action),
..
} if &action.id == action_id => Some(action.clone()),
AIAgentOutputMessage { .. } => None,
})
})
})
}
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
self.action(action_id).is_some()
}
/// Returns the exchange ID that contains the given action ID, if any.
pub fn exchange_id_for_action(&self, action_id: &AIAgentActionId) -> Option<AIAgentExchangeId> {
for task in self.task_store.tasks() {
@@ -2088,6 +2130,81 @@ impl AIConversation {
Ok(())
}
/// Reopens an exact restored exchange for continued provider projection.
///
/// This only restores the process-local stream association; it never adds input or provider
/// history, so the persisted provider run remains the sole continuation source of truth.
pub(crate) fn provider_projection_target(
&self,
response_stream_id: &ResponseStreamId,
) -> Option<(TaskId, AIAgentExchangeId)> {
let mut exchanges = self
.added_exchanges_by_response
.get(response_stream_id)?
.iter();
let target = exchanges.next()?;
exchanges
.next()
.is_none()
.then(|| (target.task_id.clone(), target.exchange_id))
}
pub(crate) fn rebind_provider_projection(
&mut self,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
let Some(task) = self.task_store.get(task_id) else {
return Err(UpdateConversationError::TaskNotFound);
};
if !task.exchanges().any(|exchange| exchange.id == exchange_id) {
return if self.exchange_with_id(exchange_id).is_some() {
Err(UpdateConversationError::ExchangeTaskMismatch)
} else {
Err(UpdateConversationError::ExchangeNotFound)
};
}
if self
.added_exchanges_by_response
.contains_key(&response_stream_id)
{
return Err(UpdateConversationError::ResponseStreamAlreadyBound);
}
let exchange = self.get_exchange_to_update(exchange_id)?;
let previous_status = std::mem::replace(
&mut exchange.output_status,
AIAgentOutputStatus::Streaming { output: None },
);
let output = match previous_status {
AIAgentOutputStatus::Streaming { output } => output,
AIAgentOutputStatus::Finished { finished_output } => match finished_output {
FinishedAIAgentOutput::Cancelled { output, .. }
| FinishedAIAgentOutput::Error { output, .. } => output,
FinishedAIAgentOutput::Success { output } => Some(output),
},
};
exchange.output_status = AIAgentOutputStatus::Streaming { output };
exchange.finish_time = None;
self.added_exchanges_by_response.insert(
response_stream_id,
Vec1::new(AddedExchange {
task_id: task_id.clone(),
exchange_id,
}),
);
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
pub fn append_reassigned_exchange(
&mut self,
response_stream_id: &ResponseStreamId,
@@ -2227,6 +2344,100 @@ impl AIConversation {
Ok(())
}
pub fn apply_domain_tool_proposal(
&mut self,
stream_id: &ResponseStreamId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
if self.contains_action(&action.id) {
return Ok(());
}
let exchange_id = self.ensure_response_exchange_for_task(
stream_id,
&action.task_id,
terminal_surface_id,
ctx,
)?;
let message_id = MessageId::new(action.id.to_string());
let exchange = self.get_exchange_to_update(exchange_id)?;
match &exchange.output_status {
AIAgentOutputStatus::Streaming {
output: Some(output),
} => output
.get_mut()
.messages
.push(AIAgentOutputMessage::action(message_id, action)),
AIAgentOutputStatus::Streaming { output: None } => {
return Err(UpdateConversationError::OutputNeverInitialized);
}
AIAgentOutputStatus::Finished { .. } => {
return Err(UpdateConversationError::OutputAlreadyFinished);
}
}
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
fn ensure_response_exchange_for_task(
&mut self,
stream_id: &ResponseStreamId,
task_id: &TaskId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<AIAgentExchangeId, UpdateConversationError> {
let added_exchanges = self
.added_exchanges_by_response
.get(stream_id)
.ok_or(UpdateConversationError::NoPendingRequest)?;
if let Some(exchange_id) = added_exchanges
.iter()
.find_map(|added| (added.task_id == *task_id).then_some(added.exchange_id))
{
return Ok(exchange_id);
}
// Direct-provider command monitoring can switch tasks within one response stream. A
// tool-first monitor turn needs an exchange before any message event can create it.
let source_exchange = added_exchanges.last().clone();
let existing_exchange = self
.task_store
.get(&source_exchange.task_id)
.ok_or(UpdateConversationError::TaskNotFound)?
.exchange(source_exchange.exchange_id)
.cloned()
.ok_or(UpdateConversationError::ExchangeNotFound)?;
let mut task = self
.task_store
.remove(task_id)
.ok_or(UpdateConversationError::TaskNotFound)?;
let exchange_id = task.append_new_exchange(&existing_exchange);
self.task_store.insert(task);
self.added_exchanges_by_response
.get_mut(stream_id)
.ok_or(UpdateConversationError::NoPendingRequest)?
.push(AddedExchange {
task_id: task_id.clone(),
exchange_id,
});
let is_hidden = self.hidden_exchanges.contains(&exchange_id);
ctx.emit(BlocklistAIHistoryEvent::AppendedExchange {
response_stream_id: Some(stream_id.clone()),
exchange_id,
task_id: task_id.clone(),
terminal_surface_id,
conversation_id: self.id,
is_hidden,
});
Ok(exchange_id)
}
pub fn update_cost_and_usage_for_request(
&mut self,
request_cost: Option<RequestCost>,
@@ -3824,6 +4035,7 @@ impl AIConversation {
.collect(),
conversation_data: AgentConversationData {
agent_backend: self.agent_backend.clone(),
active_provider_run_json: self.active_provider_run_json.clone(),
server_conversation_token: self
.server_conversation_token
.clone()
@@ -4701,6 +4913,10 @@ fn cleanup_conversation_search_temp_dir(
pub enum UpdateConversationError {
#[error("Exchange not found.")]
ExchangeNotFound,
#[error("Exchange does not belong to the persisted task.")]
ExchangeTaskMismatch,
#[error("Response stream is already bound to an exchange.")]
ResponseStreamAlreadyBound,
#[error("Could not update task: {0:?}")]
UpdateTask(#[from] UpdateTaskError),
#[error("Could not update upgrade optimistic task for server task: {0:?}")]
+25
View File
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap()
}
#[test]
fn agent_backend_does_not_change_after_successful_output() {
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
assert!(!conversation
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
}
#[test]
fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation =
@@ -184,6 +193,19 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
);
}
#[test]
fn restored_conversation_retains_active_provider_run_json() {
let snapshot = r#"{"version":1,"run":{"state":"awaiting_model"}}"#;
let conversation_data = AgentConversationData {
active_provider_run_json: Some(snapshot.to_string()),
..Default::default()
};
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.active_provider_run_json(), Some(snapshot));
}
#[test]
fn restored_conversation_uses_persisted_last_event_sequence() {
let conversation_data: AgentConversationData =
@@ -219,6 +241,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
false,
false,
AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: None,
@@ -229,6 +252,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
assert_eq!(
acp_conversation.agent_backend(),
&AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()),
@@ -247,6 +271,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
#[test]
fn restored_conversation_uses_persisted_acp_backend() {
let backend = AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()),
+80 -6
View File
@@ -7,6 +7,7 @@ pub(crate) mod comment;
pub(crate) mod icons;
pub(crate) mod linearization;
pub(crate) mod redaction;
pub(crate) mod runtime_activity;
pub(crate) mod task;
mod task_store;
pub(super) mod telemetry;
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use galaxy_agent_core::RuntimeActivity;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
@@ -579,6 +581,14 @@ impl AIAgentOutput {
pub fn format_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String {
let mut result = Vec::new();
let mut last_was_action = false;
@@ -610,8 +620,12 @@ impl AIAgentOutput {
}
AIAgentOutputMessageType::Action(action) => {
// Include action results from the action model if available
if let Some(action_model) = action_model {
if let Some(action_result) = action_model.get_action_result(&action.id) {
if let (Some(action_model), Some(conversation_id)) =
(action_model, conversation_id)
{
if let Some(action_result) =
action_model.get_action_result(conversation_id, &action.id)
{
result.push(format!("{}", MarkdownActionResult(&action_result.result)));
// Add an extra newline after tool call results for readability
result.push(String::new());
@@ -619,6 +633,13 @@ impl AIAgentOutput {
}
}
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
result.push(activity.title.clone());
if let Some(output) = &activity.output {
result.push(output.clone());
}
last_was_action = true;
}
AIAgentOutputMessageType::TodoOperation(operation) => {
result.push(format!("{operation}"));
last_was_action = false;
@@ -1213,6 +1234,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
RequestCommandOutputResult::CancelledBeforeExecution => {
write!(f, "\n_Command cancelled_")
}
RequestCommandOutputResult::ExecutionError { command, message } => {
write!(f, "\n_Command `{command}` was not executed: {message}_")
}
RequestCommandOutputResult::Denylisted { command } => {
write!(
f,
@@ -1805,6 +1829,10 @@ pub enum AIAgentOutputMessageType {
token_count: Option<u32>,
},
Subagent(SubagentCall),
/// Display-only activity executed and owned by an external agent runtime.
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
/// this activity through its action executor.
RuntimeActivity(RuntimeActivity),
Action(AIAgentAction),
TodoOperation(TodoOperation),
WebSearch(WebSearchStatus),
@@ -1972,6 +2000,12 @@ impl Display for AIAgentOutputMessage {
}
}
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
AIAgentOutputMessageType::RuntimeActivity(activity) => {
write!(f, "Runtime activity: {}", activity.title)?;
if let Some(output) = &activity.output {
write!(f, "\n{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
AIAgentOutputMessageType::WebSearch(status) => match status {
@@ -2044,6 +2078,14 @@ impl AIAgentOutputMessage {
}
}
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
Self {
id,
message: AIAgentOutputMessageType::RuntimeActivity(activity),
citations: vec![],
}
}
pub fn text(id: MessageId, text: AIAgentText) -> Self {
Self {
id,
@@ -2678,6 +2720,13 @@ pub enum AIAgentInput {
intended_agent: Option<AgentType>,
},
/// A hidden system turn that asks for the final assessment of a completed command.
CommandCompletionAssessment {
prompt: String,
context: Arc<[AIAgentContext]>,
completed_command: RunningCommand,
},
AutoCodeDiffQuery {
query: String,
context: Arc<[AIAgentContext]>,
@@ -2842,6 +2891,9 @@ impl Display for AIAgentInput {
Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
}
Self::CommandCompletionAssessment { .. } => {
write!(f, "CommandCompletionAssessment")
}
Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}")
}
@@ -2930,7 +2982,8 @@ impl AIAgentInput {
suggestion: PassiveSuggestionResultType::Prompt { prompt },
..
} => Some(prompt.clone()),
Self::AutoCodeDiffQuery { .. }
Self::CommandCompletionAssessment { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ActionResult { .. }
| Self::TriggerPassiveSuggestion { .. }
| Self::ResumeConversation { .. }
@@ -3021,6 +3074,7 @@ impl AIAgentInput {
pub fn context(&self) -> Option<&[AIAgentContext]> {
match self {
Self::UserQuery { context, .. }
| Self::CommandCompletionAssessment { context, .. }
| Self::ActionResult { context, .. }
| Self::AutoCodeDiffQuery { context, .. }
| Self::ResumeConversation { context, .. }
@@ -3054,7 +3108,8 @@ impl AIAgentInput {
Some(res)
}
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
Self::ActionResult { .. }
Self::CommandCompletionAssessment { .. }
| Self::ActionResult { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ResumeConversation { .. }
| Self::InitProjectRules { .. }
@@ -3185,9 +3240,19 @@ impl AIAgentExchange {
pub fn format_output_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_output_for_copy_for_conversation(action_model, None)
}
pub fn format_output_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String {
match self.output_status.output() {
Some(output) => output.get().format_for_copy(action_model),
Some(output) => output
.get()
.format_for_copy_for_conversation(action_model, conversation_id),
None => String::new(),
}
}
@@ -3198,9 +3263,18 @@ impl AIAgentExchange {
pub fn format_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String {
let input_text = self.format_input_for_copy();
let output_text = self.format_output_for_copy(action_model);
let output_text =
self.format_output_for_copy_for_conversation(action_model, conversation_id);
let has_user_input = !input_text.is_empty();
let has_agent_output = !output_text.is_empty();
+52 -2
View File
@@ -1,17 +1,22 @@
use std::collections::HashSet;
use std::ops::Range;
use std::sync::Arc;
use anyhow::anyhow;
use chrono::Local;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText,
AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind,
RenderableAIError, RunningCommand, TransientNetworkErrorKind,
};
use crate::ai::llms::LLMId;
use crate::server::server_api::AIApiError;
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType;
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
@@ -21,6 +26,51 @@ fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
})
}
#[test]
fn command_completion_assessment_stays_hidden_from_user_transcript() {
let context: Arc<[AIAgentContext]> =
Arc::from([AIAgentContext::SelectedText("relevant context".to_string())]);
let input = AIAgentInput::CommandCompletionAssessment {
prompt: "Report the final result.".to_string(),
context: context.clone(),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: BlockId::from("completed-command".to_string()),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
};
assert_eq!(input.display_query(), None);
assert!(!input.is_user_query());
assert!(!input.is_passive_request());
assert_eq!(input.context(), Some(context.as_ref()));
assert_eq!(input.attachments(), None);
let now = Local::now();
let exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![input],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: HashSet::new(),
start_time: now,
finish_time: None,
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-model"),
cli_agent_model_id: LLMId::from("test-model"),
computer_use_model_id: LLMId::from("test-model"),
response_initiator: None,
};
assert_eq!(exchange.format_input_for_copy(), "");
assert_eq!(exchange.format_for_copy(None), "");
assert!(!exchange.has_user_query());
}
#[test]
fn formatted_text_wrapper_shares_arc_across_calls() {
let text = FormattedText::new([FormattedTextLine::Line(vec![
+11
View File
@@ -47,6 +47,17 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
redact_secrets(&mut running_command.cursor);
}
}
AIAgentInput::CommandCompletionAssessment {
prompt,
context,
completed_command,
} => {
redact_secrets(prompt);
redact_context(Arc::make_mut(context));
redact_secrets(&mut completed_command.command);
redact_secrets(&mut completed_command.grid_contents);
redact_secrets(&mut completed_command.cursor);
}
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
redact_secrets(query);
redact_context(Arc::make_mut(context));
+12
View File
@@ -0,0 +1,12 @@
use galaxy_agent_core::RuntimeActivity;
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
}
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
serde_json::from_str(json).ok()
}
+54 -1
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use warp_multi_agent_api as api;
@@ -360,6 +360,59 @@ impl TaskStore {
append_refs_for_task(tasks, &mut refs, root_task);
}
let indexed_task_ids = refs
.iter()
.map(|exchange_ref| exchange_ref.task_id.clone())
.collect::<HashSet<_>>();
let mut direct_cli_tasks = tasks
.values()
.filter(|task| {
!indexed_task_ids.contains(task.id())
&& task.parent_id().as_ref() == Some(root_task_id)
&& task.is_cli_subagent()
&& task
.subagent_params()
.is_some_and(|params| params.tool_call_id.is_empty())
&& task.exchanges().next().is_some()
})
.collect::<Vec<_>>();
direct_cli_tasks.sort_by(|left, right| {
left.exchanges()
.next()
.map(|exchange| exchange.start_time)
.cmp(&right.exchanges().next().map(|exchange| exchange.start_time))
.then_with(|| left.id().to_string().cmp(&right.id().to_string()))
});
// Direct providers synthesize CLI monitor tasks without a parent Subagent message.
// Place each task as one chronological block so its exchanges remain reachable without
// changing the DFS order of server-linked subtasks.
for task in direct_cli_tasks {
let first_start_time = task
.exchanges()
.next()
.expect("direct CLI task was filtered to contain an exchange")
.start_time;
let insertion_index = refs
.iter()
.position(|exchange_ref| {
tasks
.get(&exchange_ref.task_id)
.and_then(|task| task.exchanges().nth(exchange_ref.exchange_index))
.is_some_and(|exchange| exchange.start_time > first_start_time)
})
.unwrap_or(refs.len());
let task_id = task.id().clone();
let task_refs = task
.exchanges()
.enumerate()
.map(|(exchange_index, _)| ExchangeRef {
task_id: task_id.clone(),
exchange_index,
})
.collect::<Vec<_>>();
refs.splice(insertion_index..insertion_index, task_refs);
}
refs
}
}
+40
View File
@@ -146,6 +146,46 @@ fn test_insert_subtask() {
assert!(store.contains(&subtask_id));
}
#[test]
fn test_unlinked_direct_cli_task_is_linearized_chronologically() {
let base_time = Local::now();
let mut root_task = Task::new_optimistic_root();
let root_task_id = root_task.id().clone();
let mut before_cli = create_test_exchange();
before_cli.start_time = base_time;
let before_cli_id = before_cli.id;
root_task.append_exchange(before_cli);
let mut after_cli = create_test_exchange();
after_cli.start_time = base_time + chrono::Duration::seconds(2);
let after_cli_id = after_cli.id;
root_task.append_exchange(after_cli);
let mut cli_task =
Task::new_optimistic_cli_agent_subtask(BlockId::new(), Some(root_task_id.to_string()));
let mut cli_exchange = create_test_exchange();
cli_exchange.start_time = base_time + chrono::Duration::seconds(1);
let cli_exchange_id = cli_exchange.id;
cli_task.append_exchange(cli_exchange);
let mut store = TaskStore::with_root_task(root_task);
store.insert(cli_task);
let exchange_ids = store
.all_exchanges()
.map(|exchange| exchange.id)
.collect::<Vec<_>>();
assert_eq!(
exchange_ids,
vec![before_cli_id, cli_exchange_id, after_cli_id]
);
assert_eq!(
store.latest_exchange().map(|exchange| exchange.id),
Some(after_cli_id)
);
}
#[test]
fn test_remove_task() {
let task = create_test_task_with_exchanges(3);
@@ -7,8 +7,8 @@ use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Shrinkable, Text,
DropShadow, Element, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::{FixedBinding, Keystroke};
@@ -22,7 +22,7 @@ use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
// Modal dimensions based on Figma design.
const MODAL_WIDTH: f32 = 440.;
const MODAL_WIDTH: f32 = 680.;
const DIALOG_CORNER_RADIUS: f32 = 8.;
const HEADER_PADDING_TOP: f32 = 24.;
@@ -40,6 +40,7 @@ const OPTIONS_VERTICAL_GAP: f32 = 8.;
const AVATAR_SIZE: f32 = 48.;
const AVATAR_ICON_SIZE: f32 = 24.;
const OPTION_HEIGHT: f32 = 136.;
const TITLE_FONT_SIZE: f32 = 16.;
const OPTION_TITLE_FONT_SIZE: f32 = 14.;
@@ -292,21 +293,25 @@ impl AgentTypeSelector {
)
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(OPTION_GAP)
.with_child(avatar)
.with_child(Shrinkable::new(1., text_content).finish())
.finish(),
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(OPTION_GAP)
.with_child(avatar)
.with_child(Shrinkable::new(1., text_content).finish())
.finish(),
)
.with_padding_left(OPTION_PADDING_HORIZONTAL)
.with_padding_right(OPTION_PADDING_HORIZONTAL)
.with_padding_top(OPTION_PADDING_VERTICAL)
.with_padding_bottom(OPTION_PADDING_VERTICAL)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(border_color))
.with_background(background)
.finish(),
)
.with_padding_left(OPTION_PADDING_HORIZONTAL)
.with_padding_right(OPTION_PADDING_HORIZONTAL)
.with_padding_top(OPTION_PADDING_VERTICAL)
.with_padding_bottom(OPTION_PADDING_VERTICAL)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(border_color))
.with_background(background)
.with_height(OPTION_HEIGHT)
.finish()
})
.with_cursor(Cursor::PointingHand)
@@ -353,11 +358,11 @@ impl AgentTypeSelector {
appearance,
);
let options = Flex::column()
let options = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(OPTIONS_VERTICAL_GAP)
.with_child(cloud_agent_option)
.with_child(local_agent_option)
.with_child(Expanded::new(1., cloud_agent_option).finish())
.with_child(Expanded::new(1., local_agent_option).finish())
.finish();
let body = Container::new(options)
@@ -26,6 +26,7 @@ pub struct ActionButtonsConfig {
pub view_details_item_id: Option<AgentConversationEntryId>,
/// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>,
pub delete_conversation_id: Option<AIConversationId>,
}
impl ActionButtonsConfig {
@@ -36,6 +37,7 @@ impl ActionButtonsConfig {
&& self.fork_conversation_id.is_none()
&& self.view_details_item_id.is_none()
&& self.copy_link_url.is_none()
&& self.delete_conversation_id.is_none()
}
/// Create config for a task.
@@ -58,6 +60,7 @@ impl ActionButtonsConfig {
fork_conversation_id: None,
view_details_item_id: None,
copy_link_url,
delete_conversation_id: None,
}
}
@@ -75,6 +78,7 @@ impl ActionButtonsConfig {
fork_conversation_id: Some(conversation_id),
view_details_item_id: None,
copy_link_url,
delete_conversation_id: Some(conversation_id),
}
}
}
@@ -87,6 +91,7 @@ pub enum AgentDetailsButtonEvent {
ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: AgentConversationEntryId },
CopyLink { link: String },
DeleteConversation { conversation_id: AIConversationId },
}
/// Actions dispatched by button clicks (internal).
@@ -97,6 +102,7 @@ pub enum AgentDetailsAction {
ForkConversation,
ViewDetails,
CopyLink,
DeleteConversation,
}
/// Reusable action buttons row for details panel.
@@ -107,6 +113,7 @@ pub struct ConversationActionButtonsRow {
fork_conversation_button: ViewHandle<ActionButton>,
view_details_button: ViewHandle<ActionButton>,
copy_link_button: ViewHandle<ActionButton>,
delete_conversation_button: ViewHandle<ActionButton>,
}
impl ConversationActionButtonsRow {
@@ -156,6 +163,15 @@ impl ConversationActionButtonsRow {
)
});
let delete_conversation_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::Trash,
"Delete conversation",
Some(AnsiColorIdentifier::Red),
AgentDetailsAction::DeleteConversation,
)
});
Self {
config: ActionButtonsConfig::default(),
open_button,
@@ -163,6 +179,7 @@ impl ConversationActionButtonsRow {
fork_conversation_button,
view_details_button,
copy_link_button,
delete_conversation_button,
}
}
@@ -231,6 +248,9 @@ impl View for ConversationActionButtonsRow {
if self.config.view_details_item_id.is_some() {
row.add_child(ChildView::new(&self.view_details_button).finish());
}
if self.config.delete_conversation_id.is_some() && !cfg!(target_family = "wasm") {
row.add_child(ChildView::new(&self.delete_conversation_button).finish());
}
row.finish()
}
@@ -280,6 +300,11 @@ impl TypedActionView for ConversationActionButtonsRow {
);
}
}
AgentDetailsAction::DeleteConversation => {
if let Some(conversation_id) = self.config.delete_conversation_id {
ctx.emit(AgentDetailsButtonEvent::DeleteConversation { conversation_id });
}
}
}
}
}
+39 -2
View File
@@ -1078,7 +1078,7 @@ impl AgentManagementView {
open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>,
) -> ActionButtonsConfig {
if let Some(task_id) = entry.identity.ambient_agent_task_id {
let mut config = if let Some(task_id) = entry.identity.ambient_agent_task_id {
ActionButtonsConfig::for_task(
task_id,
&entry.display.status,
@@ -1093,7 +1093,15 @@ impl AgentManagementView {
copy_link_url,
..Default::default()
}
};
if !entry.capabilities.can_delete
|| !entry.display.status.to_conversation_status().is_done()
{
config.delete_conversation_id = None;
}
config
}
fn handle_action_buttons_event(
@@ -1173,6 +1181,18 @@ impl AgentManagementView {
ctx.clipboard()
.write(ClipboardContent::plain_text(link.clone()));
}
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
let model = AgentConversationsModel::as_ref(ctx);
let conversation_title = model
.get_entry_by_id(item_id, ctx)
.map(|entry| entry.display.title)
.unwrap_or_else(|| "Conversation".to_string());
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
conversation_title,
terminal_view_id: None,
});
}
}
}
@@ -1395,6 +1415,16 @@ impl AgentManagementView {
notebook_uid: *notebook_uid,
});
}
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
} => {
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
conversation_title: conversation_title.clone(),
terminal_view_id: None,
});
}
}
}
@@ -2232,7 +2262,14 @@ pub enum AgentManagementViewAction {
pub enum AgentManagementViewEvent {
OpenNewTabAndRunWorkflow(Box<WorkflowType>),
OpenPlanNotebook { notebook_uid: NotebookId },
OpenPlanNotebook {
notebook_uid: NotebookId,
},
ShowDeleteConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
},
}
impl TypedActionView for AgentManagementView {
+18 -1
View File
@@ -26,6 +26,7 @@ pub mod text {
pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> {
match input {
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. }
@@ -60,6 +61,9 @@ pub mod text {
RequestCommandOutputResult::CancelledBeforeExecution => {
writeln!(w, "{CANCELLED_MESSAGE}")
}
RequestCommandOutputResult::ExecutionError { command, message } => {
writeln!(w, "Command `{command}` was not executed: {message}")
}
RequestCommandOutputResult::Denylisted { .. } => {
writeln!(
w,
@@ -432,6 +436,12 @@ pub mod text {
AIAgentActionType::RunAgents(_) => (),
AIAgentActionType::WaitForEvents { .. } => (),
},
AIAgentOutputMessageType::RuntimeActivity(activity) => {
writeln!(w, "{}", activity.title)?;
if let Some(output) = &activity.output {
writeln!(w, "{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => {
writeln!(w, "Updated TODO list:")?;
@@ -779,6 +789,7 @@ pub mod json {
match input {
// Do not include the user query, since it's already provided as input to the agent.
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. }
@@ -821,6 +832,11 @@ pub mod json {
RequestCommandOutputResult::CancelledBeforeExecution => {
Some(JsonMessage::ToolCanceled)
}
RequestCommandOutputResult::ExecutionError { message, .. } => {
Some(JsonMessage::ToolError {
error: Cow::Borrowed(message),
})
}
RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError {
error: Cow::Borrowed(
"Command was not allowed to run due to presence on denylist",
@@ -1144,7 +1160,8 @@ pub mod json {
})
}
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
| AIAgentOutputMessageType::EventsFromAgents { .. } => None,
| AIAgentOutputMessageType::EventsFromAgents { .. }
| AIAgentOutputMessageType::RuntimeActivity(_) => None,
}
}
}
+24 -4
View File
@@ -5,13 +5,15 @@ use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use galaxy_agent_core::AgentError;
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream;
use super::runtime::BedrockAgentRuntime;
use crate::ai::agent::api::LegacyResponseStream;
use crate::settings::ai::BedrockAuthMethod;
fn strip_context_marker(model_id: &str) -> String {
@@ -38,6 +40,7 @@ pub struct BedrockClientConfig {
pub secret_access_key: String,
pub session_token: Option<String>,
pub cross_region_inference: bool,
pub use_rig: bool,
}
impl BedrockClientConfig {
@@ -141,8 +144,8 @@ impl BedrockClient {
match provider.provide_credentials().await {
Ok(creds) => {
log::info!(
"[bedrock] Resolved AWS credentials successfully: access_key_id={:?}, has_session_token={}, expiry={:?}",
creds.access_key_id(),
"[bedrock] Resolved AWS credentials successfully: has_access_key_id={}, has_session_token={}, expiry={:?}",
!creds.access_key_id().is_empty(),
creds.session_token().is_some(),
creds.expiry(),
);
@@ -168,6 +171,23 @@ impl BedrockClient {
})
}
pub(crate) fn agent_runtime(
&self,
model: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<BedrockAgentRuntime, AgentError> {
BedrockAgentRuntime::new(
self.runtime_client.clone(),
model,
self.region.clone(),
cross_region_inference,
max_output_tokens,
caching_config,
)
}
#[allow(clippy::too_many_arguments)]
pub async fn converse_stream(
&self,
@@ -185,7 +205,7 @@ impl BedrockClient {
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
tool_result_archive: Vec<ConversationMessage>,
) -> Result<ResponseStream, BedrockError> {
) -> Result<LegacyResponseStream, BedrockError> {
let base_model_id = strip_context_marker(model_id);
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(&base_model_id, &self.region)
+12 -2
View File
@@ -3,8 +3,9 @@ use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock,
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
ReasoningContentBlock, ReasoningTextBlock, SystemContentBlock, Tool, ToolConfiguration,
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
ToolUseBlock,
};
use aws_smithy_types::{Blob, Document};
use serde_json::Value as JsonValue;
@@ -148,6 +149,15 @@ fn convert_messages(
.into_iter()
.map(|part| match part {
ContentPart::Text(text) => ContentBlock::Text(text),
ContentPart::Reasoning { text, signature } => {
ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(
ReasoningTextBlock::builder()
.text(text)
.set_signature(signature)
.build()
.expect("valid reasoning text block"),
))
}
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
ContentPart::ToolUse {
tool_use_id,
+8 -2
View File
@@ -258,7 +258,10 @@ fn test_system_prompt_separated_from_messages() {
None,
None,
None,
CachingConfig::default(),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert_eq!(result.system.len(), 1);
@@ -334,7 +337,10 @@ fn test_tool_definitions_produce_tool_config() {
None,
None,
None,
CachingConfig::default(),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert!(result.tool_config.is_some());
+8
View File
@@ -492,6 +492,14 @@ fn serialize_messages(messages: &[ConversationMessage]) -> JsonValue {
super::convert::ContentPart::Text(t) => {
serde_json::json!({"type": "text", "text": t})
}
super::convert::ContentPart::Reasoning { text, signature } => {
serde_json::json!({
"type": "reasoning",
"char_length": text.len(),
"has_signature": signature.is_some(),
"text": "REDACTED",
})
}
super::convert::ContentPart::Image { data, mime_type } => {
serde_json::json!({
"type": "image",
+193
View File
@@ -0,0 +1,193 @@
//! AWS Bedrock control-plane discovery.
//!
//! The foundation-model catalog is only a candidate list. Every candidate is
//! checked with `GetFoundationModelAvailability` before it is offered to the
//! user or persisted in Galaxy settings.
use aws_config::BehaviorVersion;
use aws_sdk_bedrock::Client;
use aws_sdk_bedrockruntime::config::Region;
use super::client::{BedrockClientConfig, BedrockError};
use crate::settings::ai::BedrockModelConfig;
pub async fn discover_available_models(
config: BedrockClientConfig,
) -> Result<Vec<BedrockModelConfig>, String> {
let aws_config = load_aws_config(&config)
.await
.map_err(|error| error.to_string())?;
let client = Client::new(&aws_config);
let catalog = client
.list_foundation_models()
.send()
.await
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
let mut models = Vec::new();
for summary in catalog.model_summaries() {
let model_id = summary.model_id();
let availability = match client
.get_foundation_model_availability()
.model_id(model_id)
.send()
.await
{
Ok(availability) => availability,
Err(error) => {
log::debug!(
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
);
continue;
}
};
if !model_availability_is_usable(
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str()),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
) {
log::debug!(
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str())
.unwrap_or("MISSING"),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
);
continue;
}
let display_name = summary
.model_name()
.map(str::to_owned)
.unwrap_or_else(|| prettify_model_id(model_id));
let vision_supported = summary
.input_modalities()
.iter()
.any(|modality| modality.as_str() == "IMAGE");
models.push(BedrockModelConfig {
model_id: model_id.to_owned(),
display_name,
vision_supported,
use_rig: false,
});
}
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
if models.is_empty() {
return Err(
"AWS returned no Bedrock models that are authorized and available in this region."
.to_string(),
);
}
Ok(models)
}
fn model_availability_is_usable(
agreement_status: Option<&str>,
authorization_status: &str,
entitlement_status: &str,
region_status: &str,
) -> bool {
agreement_status == Some("AVAILABLE")
&& authorization_status == "AUTHORIZED"
&& entitlement_status == "AVAILABLE"
&& region_status == "AVAILABLE"
}
async fn load_aws_config(
config: &BedrockClientConfig,
) -> Result<aws_config::SdkConfig, BedrockError> {
let sdk_config = match config.auth_method {
crate::settings::ai::BedrockAuthMethod::Profile
| crate::settings::ai::BedrockAuthMethod::Sso => {
let mut loader = aws_config::defaults(BehaviorVersion::latest());
if !config.profile.is_empty() && config.profile != "default" {
loader = loader.profile_name(&config.profile);
}
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
}
loader.load().await
}
crate::settings::ai::BedrockAuthMethod::StaticKeys => {
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
return Err(BedrockError::CredentialsNotConfigured);
}
let credentials = aws_credential_types::Credentials::new(
&config.access_key_id,
&config.secret_access_key,
config.session_token.clone(),
None,
"galaxy-bedrock-discovery",
);
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials);
loader = loader.region(Region::new(if config.region.is_empty() {
"us-east-1".to_string()
} else {
config.region.clone()
}));
loader.load().await
}
};
if sdk_config.region().is_none() {
return Err(BedrockError::RegionNotConfigured);
}
Ok(sdk_config)
}
fn prettify_model_id(model_id: &str) -> String {
model_id
.rsplit('.')
.next()
.unwrap_or(model_id)
.replace(['-', ':'], " ")
}
#[cfg(test)]
mod tests {
use super::model_availability_is_usable;
#[test]
fn requires_every_availability_status() {
assert!(model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
None,
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"NOT_AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"NOT_AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"NOT_AVAILABLE",
));
}
}
+1
View File
@@ -269,6 +269,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
+1
View File
@@ -144,6 +144,7 @@ fn parse_claude_code_model_map(
model_id: arn,
display_name,
vision_supported: true,
use_rig: false,
}
})
.collect()
+1
View File
@@ -24,6 +24,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
+2 -1
View File
@@ -2,12 +2,13 @@ pub mod client;
pub mod convert;
pub mod crash_log;
pub mod diagnostic;
pub mod discovery;
pub mod external_config;
pub mod models;
pub mod request_translator;
pub mod response_translator;
pub mod runtime;
pub mod settings_view;
pub mod translator;
#[cfg(test)]
mod convert_tests;
+23 -143
View File
@@ -1,152 +1,32 @@
#![allow(dead_code)]
use super::external_config::ExternalBedrockConfig;
use crate::settings::ai::BedrockModelConfig;
pub struct DefaultModel {
pub model_id: &'static str,
pub display_name: &'static str,
pub vision_supported: bool,
pub context_size: u32,
pub fn configured_model_uses_rig(
selected_model_id: &str,
configured_models: &[BedrockModelConfig],
region: &str,
cross_region_inference: bool,
) -> bool {
let selected_model_id = strip_context_marker(selected_model_id);
configured_models.iter().any(|model| {
if !model.use_rig {
return false;
}
let configured_model_id = strip_context_marker(&model.model_id);
if configured_model_id == selected_model_id {
return true;
}
galaxy_agent_rig::resolve_bedrock_model_id(&model.model_id, region, cross_region_inference)
.is_ok_and(|resolved| strip_context_marker(&resolved) == selected_model_id)
})
}
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
DefaultModel {
model_id: "us.anthropic.claude-opus-4-6-v1[1m]",
display_name: "Claude Opus 4.6 (1M)",
vision_supported: true,
context_size: 1_000_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
display_name: "Claude Sonnet 4.6 (1M)",
vision_supported: true,
context_size: 1_000_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
display_name: "Claude 3 Sonnet",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
display_name: "Claude Opus 4.1",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-6-v1",
display_name: "Claude Opus 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
display_name: "Claude 3 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
display_name: "Claude 3.5 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
];
pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockModelConfig> {
if !user_models.is_empty() {
return user_models.to_vec();
}
// Fall back to models from external configs (Claude Code / OpenCode)
let external = ExternalBedrockConfig::load();
if !external.models.is_empty() {
log::info!(
"[bedrock] Using {} model(s) from external config",
external.models.len()
);
// Merge external models with defaults so the user still sees all defaults
let mut models = external.models;
let defaults: Vec<BedrockModelConfig> = DEFAULT_BEDROCK_MODELS
.iter()
.map(|m| BedrockModelConfig {
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
})
.collect();
for default in defaults {
if !models.iter().any(|m| m.model_id == default.model_id) {
models.push(default);
}
}
return models;
}
DEFAULT_BEDROCK_MODELS
.iter()
.map(|m| BedrockModelConfig {
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
})
.collect()
fn strip_context_marker(model_id: &str) -> &str {
model_id
.strip_suffix("[1m]")
.or_else(|| model_id.strip_suffix("[1M]"))
.unwrap_or(model_id)
}
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
+29 -20
View File
@@ -78,28 +78,37 @@ fn test_cross_region_prefix_unknown_region() {
);
}
#[test]
fn test_get_effective_models_empty_returns_defaults() {
let models = get_effective_models(&[]);
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6[1m]");
assert_eq!(models[0].display_name, "Claude Opus 4.6");
}
#[test]
fn test_get_effective_models_custom_overrides() {
let custom = vec![BedrockModelConfig {
model_id: "custom.model-v1:0".to_string(),
display_name: "Custom Model".to_string(),
vision_supported: false,
}];
let models = get_effective_models(&custom);
assert_eq!(models.len(), 1);
assert_eq!(models[0].model_id, "custom.model-v1:0");
}
#[test]
fn test_cross_region_prefix_skips_arn() {
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
}
#[test]
fn rig_opt_in_matches_context_markers_and_resolved_inference_profiles() {
let configured = vec![BedrockModelConfig {
model_id: "anthropic.claude-test[1m]".to_string(),
display_name: "Claude Test".to_string(),
vision_supported: false,
use_rig: true,
}];
assert!(configured_model_uses_rig(
"us.anthropic.claude-test",
&configured,
"us-east-1",
true,
));
assert!(configured_model_uses_rig(
"anthropic.claude-test[1M]",
&configured,
"us-east-1",
false,
));
assert!(!configured_model_uses_rig(
"anthropic.other-model",
&configured,
"us-east-1",
false,
));
}
+188 -67
View File
@@ -7,6 +7,7 @@ use warp_multi_agent_api as api;
use super::convert::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
};
use crate::ai::agent::api::mark_internal_command_completion_assessment;
/// Command-monitor turns must wake often enough to react to steering and user-specified deadlines.
///
@@ -91,32 +92,11 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
) => {
if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() {
let query_text =
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!(
"[Command ID: {}]\n",
snapshot.command_id
));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
user_queries.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query_text),
content: MessageContent::Text(cli_query_text(
cli_query, user_query,
)),
});
}
}
@@ -565,24 +545,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
) => {
if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() {
let query_text =
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
results.push(api::Message {
let mut message = api::Message {
id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(),
request_id: String::new(),
@@ -592,11 +555,15 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
fetched_memories: vec![],
message: Some(api::message::Message::UserQuery(
api::message::UserQuery {
query: query_text,
query: cli_query_text(cli_query, user_query),
..Default::default()
},
)),
});
};
if cli_query_is_completed_assessment(cli_query) {
mark_internal_command_completion_assessment(&mut message);
}
results.push(message);
}
}
}
@@ -750,6 +717,7 @@ fn persist_input_images_on_latest_user_message(
Some(api::input_context::Image { data, mime_type })
}
Some(ContentPart::Text(_))
| Some(ContentPart::Reasoning { .. })
| Some(ContentPart::ToolUse { .. })
| Some(ContentPart::ToolResult { .. })
| None => None,
@@ -873,11 +841,17 @@ fn is_pure_tool_result(content: &MessageContent) -> bool {
fn strip_tool_result_parts(content: &mut MessageContent) {
if let MessageContent::MultiPart(parts) = content {
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse {
tool_use_id,
name,
@@ -916,11 +890,17 @@ fn strip_orphaned_tool_result_parts(
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
_ => true,
});
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse {
tool_use_id,
name,
@@ -1222,12 +1202,57 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections:
}
}
fn cli_query_is_completed_assessment(cli_query: &api::request::input::CliAgentUserQuery) -> bool {
cli_query
.user_query
.as_ref()
.is_some_and(|query| query.intended_agent() == api::AgentType::Primary)
}
fn cli_query_text(
cli_query: &api::request::input::CliAgentUserQuery,
user_query: &api::request::input::UserQuery,
) -> String {
let Some(command) = &cli_query.running_command else {
return user_query.query.clone();
};
let completed = cli_query_is_completed_assessment(cli_query);
let mut context = format!(
"[{}: {}]\n",
if completed {
"Completed command"
} else {
"Running command"
},
command.command
);
if let Some(snapshot) = &command.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!("[Command ID: {}]\n", snapshot.command_id));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[{}:\n{}\n]\n",
if completed {
"Final terminal output"
} else {
"Terminal output"
},
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentMode {
Normal,
Plan,
Orchestrate,
Cli,
CompletedCommandAssessment,
}
fn request_agent_mode(request: &api::Request) -> AgentMode {
@@ -1239,6 +1264,17 @@ fn request_agent_mode(request: &api::Request) -> AgentMode {
return AgentMode::Normal;
};
if user_inputs.inputs.iter().any(|user_input| {
matches!(
&user_input.input,
Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
cli_query
)) if cli_query_is_completed_assessment(cli_query)
)
}) {
return AgentMode::CompletedCommandAssessment;
}
let mut mode = AgentMode::Normal;
for user_input in &user_inputs.inputs {
match &user_input.input {
@@ -1483,16 +1519,31 @@ pub fn extract_system_prompt(
"This turn concerns a running or just-finished shell command. Act as its dedicated \
monitor while still following the user's steering messages. Use the command ID from \
the running-command context or tool result for every read/write operation. If the \
result says the command finished, report its outcome and stop polling. Otherwise, \
poll with `read_shell_command_output` and use short delays. Never choose a poll \
interval that crosses a user-specified deadline or stop condition. When an explicit \
stop condition is met, call `interrupt_shell_command` immediately, then poll briefly \
to verify the outcome. Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or \
`\\u0003` through `write_to_long_running_shell_command`; that tool is only for actual \
process input. Never start a duplicate command merely to check its state, and never \
report completion while a result says it is still running. If user interaction is \
the right next step and the transfer tool is available, transfer control with a \
clear reason.\n\n",
result says the command finished, report its outcome and stop polling. If it says the \
command is still running, the next assistant output MUST be a tool call. Use \
`read_shell_command_output` with a short delay for normal progress. If the snapshot \
clearly shows an interactive pager or editor, do not keep polling: an alternate screen \
containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input \
`q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode \
`line`. Poll briefly after sending quit input to verify the outcome. Call \
`interrupt_shell_command` immediately when the user's explicit stop condition is met. \
Do not end a still-running monitor turn with prose, a status message, or a request for \
the user to say continue. Never choose a poll interval that crosses a user-specified \
deadline or stop condition. After an interrupt, poll briefly to verify the outcome. \
Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or `\\u0003` through \
`write_to_long_running_shell_command`; that tool is only for actual process input. \
Never start a duplicate command merely to check its state, and never report completion \
while a result says it is still running. If user interaction is the right next step and \
the transfer tool is available, transfer control with a clear reason.\n\n",
);
}
AgentMode::CompletedCommandAssessment => {
prompt.push_str("## Completed Command Assessment\n");
prompt.push_str(
"The monitored command has finished. Use its command, command ID, final terminal \
output, and the assessment instruction in the latest hidden input to provide the \
final user-facing outcome. Do not continue polling, request more terminal output, \
or call tools.\n\n",
);
}
}
@@ -1543,6 +1594,10 @@ pub fn extract_system_prompt(
}
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
if request_agent_mode(request) == AgentMode::CompletedCommandAssessment {
return Vec::new();
}
let mut tools = default_tool_definitions();
let mut seen_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name.clone()).collect();
@@ -1604,10 +1659,12 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
let settings = request.settings.as_ref()?;
let raw_tools = if request_agent_mode(request) == AgentMode::Cli {
&settings.supported_cli_agent_tools
} else {
&settings.supported_tools
let raw_tools = match request_agent_mode(request) {
AgentMode::Cli => &settings.supported_cli_agent_tools,
AgentMode::Normal
| AgentMode::Plan
| AgentMode::Orchestrate
| AgentMode::CompletedCommandAssessment => &settings.supported_tools,
};
Some(
raw_tools
@@ -1617,7 +1674,7 @@ fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>
)
}
fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
use api::ToolType;
let has = |tool| supported.contains(&tool);
@@ -1639,7 +1696,7 @@ fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> boo
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"ask_user_question" => has(ToolType::AskUserQuestion),
"read_skill" => has(ToolType::ReadSkill),
"fetch_conversation" => has(ToolType::FetchConversation),
@@ -1841,7 +1898,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "create_plan".to_string(),
description: "Create a new plan document in Galaxy Drive's Plans folder. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(),
description: "Create a new plan document in Galaxy Drive's Plans folder. When the user asks to create a plan for review, use this tool after completing the necessary research instead of only returning plan prose. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -1894,6 +1951,68 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
"required": ["diffs"]
}),
},
ToolDefinition {
name: "run_agents".to_string(),
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and inherit the parent model. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"summary": { "type": "string", "description": "Brief explanation of why child agents help with this task" },
"base_prompt": { "type": "string", "default": "", "description": "Instructions prepended to every child prompt" },
"skills": {
"type": "array",
"items": {
"type": "object",
"properties": {
"skill": { "type": "string" },
"reference_type": { "type": "string", "enum": ["path", "bundled"] }
},
"required": ["skill", "reference_type"]
}
},
"model_id": { "type": "string", "default": "", "description": "Optional child model override; empty inherits the parent model" },
"harness_type": { "type": "string", "default": "", "description": "Optional harness identifier; empty selects the embedded local child runtime" },
"execution_mode": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["local", "remote"], "default": "local" },
"environment_id": { "type": "string", "default": "" },
"worker_host": { "type": "string", "default": "" },
"computer_use_enabled": { "type": "boolean", "default": false }
}
},
"agent_run_configs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Unique child name" },
"prompt": { "type": "string", "default": "", "description": "Child-specific instructions" },
"title": { "type": "string", "default": "", "description": "Optional display title" }
},
"required": ["name", "prompt"]
}
},
"plan_id": { "type": "string", "default": "", "description": "Optional associated plan document ID" }
},
"required": ["summary", "agent_run_configs"]
}),
},
ToolDefinition {
name: "wait_for_events".to_string(),
description: "Yield after starting child agents or other asynchronous work. Use this when you are waiting for child-agent updates instead of repeating the same investigation yourself.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"idle_timeout_seconds": {
"type": "integer",
"default": 0,
"description": "Optional idle timeout. 0 lets Galaxy choose the default."
}
}
}),
},
ToolDefinition {
name: "start_agent".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
@@ -2380,10 +2499,12 @@ fn long_running_command_content(snapshot: &api::LongRunningShellCommandSnapshot)
};
format!(
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
Continue monitoring with `read_shell_command_output` using command_id `{}`. \
Use `write_to_long_running_shell_command` with the same command_id only if input is \
required. If the user's explicit stop condition is met, use `interrupt_shell_command` \
with the same command_id. Do not report the command as complete while it is still running.",
The next assistant output MUST be a tool call: continue monitoring with \
`read_shell_command_output` using command_id `{}` and a short wait. Use \
`write_to_long_running_shell_command` with the same command_id only if input is required. \
If the user's explicit stop condition is met, use `interrupt_shell_command` immediately \
with the same command_id. Do not end this turn with prose or report the command as complete \
while it is still running.",
snapshot.command_id, output, snapshot.command_id
)
}
@@ -5,6 +5,7 @@ use super::{
convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt,
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test]
@@ -99,6 +100,7 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
vec![
"run_shell_command",
"read_files",
"run_agents",
"start_agent",
"recall_tool_history"
]
@@ -236,6 +238,117 @@ fn plan_mode_prompt_prohibits_mutation() {
assert!(prompt.contains("do not edit files"));
}
fn completed_command_request() -> api::Request {
api::Request {
task_context: Some(api::request::TaskContext {
tasks: vec![api::Task {
id: "root-task".to_string(),
..Default::default()
}],
}),
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: "Report the final outcome.".to_string(),
intended_agent: api::AgentType::Primary.into(),
..Default::default()
}),
running_command: Some(api::RunningShellCommand {
command: "cargo test -p galaxy".to_string(),
snapshot: Some(api::LongRunningShellCommandSnapshot {
command_id: "completed-block-123".to_string(),
output: "test result: ok".to_string(),
cursor: "cursor".to_string(),
..Default::default()
}),
}),
..Default::default()
},
),
),
}],
},
)),
..Default::default()
}),
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::RunShellCommand.into(),
api::ToolType::ReadFiles.into(),
api::ToolType::CallMcpTool.into(),
],
supported_cli_agent_tools: vec![api::ToolType::ReadShellCommandOutput.into()],
..Default::default()
}),
mcp_context: Some(api::request::McpContext {
servers: vec![api::request::mcp_context::McpServer {
id: "server-id".to_string(),
name: "test-server".to_string(),
description: String::new(),
resources: Vec::new(),
tools: vec![api::request::mcp_context::McpTool {
name: "echo".to_string(),
description: "Echo input".to_string(),
input_schema: None,
}],
}],
..Default::default()
}),
..Default::default()
}
}
#[test]
fn completed_command_assessment_is_tool_free_and_persists_hidden_provider_history() {
let mut request = completed_command_request();
let messages = extract_new_input_messages(&request);
assert_eq!(messages.len(), 1);
assert!(matches!(
&messages[0].content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("[Command ID: completed-block-123]")
&& text.contains("[Final terminal output:\ntest result: ok")
&& text.contains("Report the final outcome.")
));
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
assert!(prompt.contains("## Completed Command Assessment"));
assert!(prompt.contains("No tools are available for this request"));
assert!(!prompt.contains("## Running Command Monitor"));
assert!(!prompt.contains("next assistant output MUST be a tool call"));
assert!(extract_tools(&request).is_empty());
inject_input_messages_into_task(&mut request);
let persisted = &request.task_context.as_ref().expect("task context").tasks[0].messages;
assert_eq!(persisted.len(), 1);
assert!(is_internal_command_completion_assessment(&persisted[0]));
assert!(matches!(
persisted[0].message.as_ref(),
Some(api::message::Message::UserQuery(query))
if query.query.contains("[Completed command: cargo test -p galaxy]")
&& query.query.contains("[Command ID: completed-block-123]")
&& query.query.contains("[Final terminal output:\ntest result: ok")
&& query.query.contains("Report the final outcome.")
));
let restored = convert_proto_message_for_test(&persisted[0])
.expect("hidden assessment should remain in provider history");
assert_eq!(restored.role, MessageRole::User);
assert!(matches!(
restored.content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("Report the final outcome.")
));
}
#[test]
fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
let request = api::Request {
@@ -299,6 +412,10 @@ fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
assert!(prompt.contains("command ID"));
assert!(prompt.contains("read_shell_command_output"));
assert!(prompt.contains("interrupt_shell_command"));
assert!(prompt.contains("next assistant output MUST be a tool call"));
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
assert!(prompt.contains("`write_to_long_running_shell_command` with input `q` and mode `raw`"));
assert!(prompt.contains("Do not end a still-running monitor turn with prose"));
assert!(prompt.contains("Never try to encode Ctrl+C"));
assert!(!prompt.contains("- Use `run_shell_command`"));
+137 -142
View File
@@ -8,13 +8,14 @@ use aws_sdk_bedrockruntime::types::{
ReasoningContentBlockDelta, StopReason,
};
use futures::stream::BoxStream;
use galaxy_agent_core::{recall_tool_history, ToolHistoryQuery};
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::diagnostic::BedrockDiagnosticLogger;
use crate::ai::agent::api::Event;
use crate::ai::agent::api::LegacyEvent;
use crate::server::server_api::AIApiError;
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
@@ -68,7 +69,7 @@ pub fn bedrock_stream_to_response_events(
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String,
tool_result_archive: Vec<ConversationMessage>,
) -> BoxStream<'static, Event> {
) -> BoxStream<'static, LegacyEvent> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
@@ -231,13 +232,15 @@ pub fn bedrock_stream_to_response_events(
.unwrap_or(0) as usize;
let recall_result = match messages_sent.lock() {
Ok(sent) => recall_from_history(
Ok(sent) => recall_tool_history(
&sent,
&tool_result_archive,
search_query,
tool_name_filter,
tool_use_id,
offset,
ToolHistoryQuery {
search_query,
tool_name: tool_name_filter,
tool_use_id,
offset_from_end: offset,
},
),
Err(_) => "Error: could not access conversation history.".to_string(),
};
@@ -319,7 +322,7 @@ pub fn bedrock_stream_to_response_events(
current_tool_name, current_tool_use_id, current_tool_input_json
));
}
if current_tool_name == "start_agent" {
if matches!(current_tool_name.as_str(), "start_agent" | "run_agents") {
has_start_agent_calls = true;
}
let tool_msg = build_tool_call_message(
@@ -1241,6 +1244,78 @@ pub fn build_tool_call_message(
api::message::tool_call::EditDocuments { diffs },
))
}
"run_agents" => {
let agent_run_configs = input
.get("agent_run_configs")
.and_then(|value| value.as_array())
.map(|configs| {
configs
.iter()
.map(|config| api::run_agents::AgentRunConfig {
name: config
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
prompt: config
.get("prompt")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
title: config
.get("title")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
})
.collect()
})
.unwrap_or_default();
let execution_mode = input
.get("execution_mode")
.and_then(run_agents_execution_mode_from_json);
Some(api::message::tool_call::Tool::RunAgents(api::RunAgents {
summary: input
.get("summary")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
base_prompt: input
.get("base_prompt")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
skills: Vec::new(),
model_id: input
.get("model_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
harness: input
.get("harness_type")
.and_then(|value| value.as_str())
.and_then(run_agents_harness_from_str),
agent_run_configs,
execution_mode,
plan_id: input
.get("plan_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
}))
}
"wait_for_events" => {
let idle_timeout_seconds = input
.get("idle_timeout_seconds")
.and_then(|value| value.as_i64())
.and_then(|value| value.try_into().ok())
.unwrap_or(0);
Some(api::message::tool_call::Tool::WaitForEvents(
api::message::tool_call::WaitForEvents {
idle_timeout_seconds,
},
))
}
"start_agent" => {
let name = input
.get("name")
@@ -1467,6 +1542,58 @@ pub fn build_tool_call_message(
}
}
fn run_agents_execution_mode_from_json(
execution_mode: &serde_json::Value,
) -> Option<api::run_agents::ExecutionMode> {
let mode_type = execution_mode
.get("type")
.and_then(|value| value.as_str())
.or_else(|| execution_mode.as_str());
match mode_type {
Some("remote") => Some(api::run_agents::ExecutionMode::Remote(
api::run_agents::Remote {
environment_id: execution_mode
.get("environment_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
worker_host: execution_mode
.get("worker_host")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
computer_use_enabled: execution_mode
.get("computer_use_enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false),
},
)),
Some("local") | Some(_) | None => Some(api::run_agents::ExecutionMode::Local(
api::run_agents::Local {},
)),
}
}
fn run_agents_harness_from_str(harness_type: &str) -> Option<api::Harness> {
let variant = match harness_type
.trim()
.to_ascii_lowercase()
.replace('_', "-")
.as_str()
{
"oz" => api::harness::Variant::Oz(api::harness::Oz {}),
"claude" | "claude-code" => api::harness::Variant::ClaudeCode(api::harness::ClaudeCode {}),
"opencode" | "open-code" => api::harness::Variant::OpenCode(api::harness::OpenCode {}),
"gemini" => api::harness::Variant::Gemini(api::harness::Gemini {}),
"codex" => api::harness::Variant::Codex(api::harness::Codex {}),
"" | "unknown" => return None,
_ => return None,
};
Some(api::Harness {
variant: Some(variant),
})
}
/// Built-in tools that Galaxy knows how to execute directly.
const KNOWN_TOOLS: &[&str] = &[
"run_shell_command",
@@ -1490,6 +1617,8 @@ const KNOWN_TOOLS: &[&str] = &[
"read_documents",
"create_documents",
"edit_documents",
"run_agents",
"wait_for_events",
"start_agent",
"ask_user_question",
"read_skill",
@@ -1504,137 +1633,3 @@ pub(super) fn is_known_tool(name: &str) -> bool {
fn is_notebook_tool(name: &str) -> bool {
matches!(name, "create_notebook" | "read_notebook" | "edit_notebook")
}
/// Searches conversation message history for tool call results matching the given criteria.
pub(crate) fn recall_from_history(
messages: &[ConversationMessage],
archive: &[ConversationMessage],
search_query: &str,
tool_name_filter: &str,
tool_use_id: &str,
offset_from_end: usize,
) -> String {
use super::convert::{ContentPart, MessageContent};
struct ToolEntry {
tool_use_id: String,
name: String,
input: String,
result: String,
}
let mut tool_entries: Vec<ToolEntry> = Vec::new();
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
for msg in messages.iter().chain(archive.iter()) {
match &msg.content {
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
}
MessageContent::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((
tool_use_id.clone(),
name.clone(),
input.to_string(),
));
}
ContentPart::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
_ => {}
}
}
}
_ => {}
}
}
let filtered: Vec<&ToolEntry> = tool_entries
.iter()
.filter(|entry| {
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
return false;
}
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
return false;
}
if !search_query.is_empty() {
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
let query_lower = search_query.to_lowercase();
if !haystack.to_lowercase().contains(&query_lower) {
return false;
}
}
true
})
.collect();
if filtered.is_empty() {
return "No matching tool calls found in conversation history.".to_string();
}
// Get the entry at offset_from_end (0 = most recent)
let idx = if offset_from_end >= filtered.len() {
0
} else {
filtered.len() - 1 - offset_from_end
};
let entry = &filtered[idx];
let result_display = if entry.result.len() > 50000 {
let trunc = entry.result.chars().take(50000).collect::<String>();
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
} else {
entry.result.clone()
};
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
entry.name, entry.tool_use_id, entry.input, result_display
)
}
@@ -350,6 +350,56 @@ fn development_tool_calls_preserve_focused_reads_file_lifecycle_and_search_filte
assert_eq!(search.path_filters, vec!["app/src/ai", "crates/ai"]);
}
#[test]
fn orchestration_tool_calls_build_run_agents_and_wait_for_events() {
let run_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-run-agents",
"run_agents",
r#"{
"summary": "Investigate in parallel",
"base_prompt": "Shared instructions",
"model_id": "coding-assistant-max",
"harness_type": "codex",
"execution_mode": {
"type": "local"
},
"agent_run_configs": [
{
"name": "code",
"prompt": "Inspect code",
"title": "Code inspection"
}
],
"plan_id": "plan-1"
}"#,
));
let api::message::tool_call::Tool::RunAgents(run_agents) = run_tool else {
panic!("expected run_agents");
};
assert_eq!(run_agents.summary, "Investigate in parallel");
assert_eq!(run_agents.base_prompt, "Shared instructions");
assert_eq!(run_agents.model_id, "coding-assistant-max");
assert!(matches!(
run_agents.execution_mode,
Some(api::run_agents::ExecutionMode::Local(_))
));
assert_eq!(run_agents.agent_run_configs.len(), 1);
assert_eq!(run_agents.agent_run_configs[0].name, "code");
assert_eq!(run_agents.agent_run_configs[0].prompt, "Inspect code");
let wait_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-wait",
"wait_for_events",
r#"{"idle_timeout_seconds": 120}"#,
));
let api::message::tool_call::Tool::WaitForEvents(wait) = wait_tool else {
panic!("expected wait_for_events");
};
assert_eq!(wait.idle_timeout_seconds, 120);
}
#[test]
fn test_context_window_for_model_1m_marker() {
assert_eq!(
@@ -467,6 +517,8 @@ fn test_cost_zero_for_zero_tokens() {
fn direct_provider_known_tools_exclude_hosted_only_tools() {
assert!(!is_known_tool("send_message_to_agent"));
assert!(!is_known_tool("suggest_next_prompt"));
assert!(is_known_tool("run_agents"));
assert!(is_known_tool("wait_for_events"));
assert!(is_known_tool("recall_tool_history"));
assert!(is_known_tool("interrupt_shell_command"));
}
+463
View File
@@ -0,0 +1,463 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
use aws_sdk_bedrockruntime::types::{
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as AwsStreamEvent,
ReasoningContentBlockDelta, StopReason as AwsStopReason,
};
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
use futures::{FutureExt, StreamExt};
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, StopReason, ToolCall, ToolEvent, TurnCommand, TurnControl,
TurnRequest, Usage,
};
use uuid::Uuid;
use super::convert::{build_converse_request, CachingConfig, ConvertedRequest};
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64_000;
#[derive(Clone)]
pub(crate) struct BedrockAgentRuntime {
client: AwsBedrockClient,
resolved_model: String,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
descriptor: RuntimeDescriptor,
}
impl BedrockAgentRuntime {
pub(crate) fn new(
client: AwsBedrockClient,
configured_model: String,
region: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<Self, AgentError> {
let resolved_model = galaxy_agent_rig::resolve_bedrock_model_id(
&configured_model,
&region,
cross_region_inference,
)?;
let descriptor = RuntimeDescriptor {
id: format!("bedrock:{resolved_model}"),
display_name: format!("Bedrock / {resolved_model}"),
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Ok(Self {
client,
resolved_model,
max_output_tokens,
caching_config,
descriptor,
})
}
}
#[async_trait]
impl AgentRuntime for BedrockAgentRuntime {
fn descriptor(&self) -> &RuntimeDescriptor {
&self.descriptor
}
async fn start_turn(
&self,
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let converted =
convert_turn_request(request, self.max_output_tokens, self.caching_config.clone());
let mut request = self
.client
.converse_stream()
.model_id(&self.resolved_model)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
if let Some(tool_config) = converted.tool_config {
request = request.tool_config(tool_config);
}
let runtime_request_id = Uuid::new_v4().to_string();
let send_future = request.send().fuse();
let initial_control = control.clone();
let control_future = initial_control.receive().fuse();
futures::pin_mut!(send_future, control_future);
let output = futures::select_biased! {
command = control_future => match command {
Ok(TurnCommand::Cancel) => {
return Ok(stopped_before_stream(runtime_request_id));
}
Ok(TurnCommand::Steer { .. }) | Err(_) => {
send_future.await.map_err(map_bedrock_error)?
}
},
result = send_future => result.map_err(map_bedrock_error)?,
};
Ok(translate_bedrock_stream(
output,
runtime_request_id,
control,
))
}
}
fn convert_turn_request(
request: TurnRequest,
configured_max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> ConvertedRequest {
let max_output_tokens = request
.max_output_tokens
.or(configured_max_output_tokens)
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
.min(i32::MAX as u64) as i32;
build_converse_request(
request.messages,
request.system_prompt,
None,
request.tools,
max_output_tokens,
None,
None,
None,
caching_config,
)
}
fn translate_bedrock_stream(
mut output: ConverseStreamOutput,
runtime_request_id: String,
control: TurnControl,
) -> AgentEventStream {
let events = async_stream::stream! {
yield Ok(AgentEvent::TurnStarted {
runtime_request_id,
});
let mut translator = BedrockStreamTranslator::default();
let mut control_open = true;
loop {
let next_event = output.stream.recv().fuse();
let next_command = if control_open {
futures::future::Either::Left(control.receive())
} else {
futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(next_event, next_command);
let event = futures::select_biased! {
command = next_command => {
match command {
Ok(TurnCommand::Cancel) => {
yield Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
});
return;
}
Ok(TurnCommand::Steer { .. }) => continue,
Err(_) => {
control_open = false;
continue;
}
}
}
event = next_event => event,
};
match event {
Ok(Some(event)) => match translator.translate(event) {
Ok(events) => {
for event in events {
yield Ok(event);
}
}
Err(error) => {
yield Err(error);
return;
}
},
Ok(None) => match translator.finish() {
Ok(events) => {
for event in events {
yield Ok(event);
}
return;
}
Err(error) => {
yield Err(error);
return;
}
},
Err(error) => {
yield Err(map_bedrock_error(error));
return;
}
}
}
};
Box::pin(events)
}
#[derive(Default)]
struct BedrockStreamTranslator {
content_blocks: BTreeMap<i32, PendingContentBlock>,
stop_reason: Option<StopReason>,
}
impl BedrockStreamTranslator {
fn translate(&mut self, event: AwsStreamEvent) -> Result<Vec<AgentEvent>, AgentError> {
match event {
AwsStreamEvent::MessageStart(_) => Ok(Vec::new()),
AwsStreamEvent::ContentBlockStart(start) => {
let Some(block_start) = start.start() else {
return Ok(Vec::new());
};
let ContentBlockStart::ToolUse(tool) = block_start else {
return Err(protocol_error(
"Bedrock started an unsupported output content block",
));
};
let index = start.content_block_index();
if self
.content_blocks
.insert(
index,
PendingContentBlock::Tool {
id: tool.tool_use_id().to_string(),
name: tool.name().to_string(),
input: String::new(),
},
)
.is_some()
{
return Err(protocol_error(format!(
"Bedrock started content block {index} more than once"
)));
}
Ok(Vec::new())
}
AwsStreamEvent::ContentBlockDelta(delta) => {
let Some(delta_value) = delta.delta() else {
return Err(protocol_error("Bedrock emitted an empty content delta"));
};
let index = delta.content_block_index();
match delta_value {
ContentBlockDelta::Text(text) => {
Ok(vec![AgentEvent::TextDelta { text: text.clone() }])
}
ContentBlockDelta::ReasoningContent(reasoning) => {
let block = self.content_blocks.entry(index).or_insert_with(|| {
PendingContentBlock::Reasoning {
text: String::new(),
signature: None,
}
});
let PendingContentBlock::Reasoning { text, signature } = block else {
return Err(protocol_error(format!(
"Bedrock mixed reasoning and tool data in content block {index}"
)));
};
match reasoning {
ReasoningContentBlockDelta::Text(delta) => {
text.push_str(delta);
Ok(vec![AgentEvent::ReasoningDelta {
text: delta.clone(),
}])
}
ReasoningContentBlockDelta::Signature(delta) => {
signature.get_or_insert_with(String::new).push_str(delta);
Ok(Vec::new())
}
ReasoningContentBlockDelta::RedactedContent(_) => Ok(Vec::new()),
_ => Err(protocol_error("Bedrock emitted an unknown reasoning delta")),
}
}
ContentBlockDelta::ToolUse(tool_delta) => {
let Some(PendingContentBlock::Tool { input, .. }) =
self.content_blocks.get_mut(&index)
else {
return Err(protocol_error(format!(
"Bedrock emitted tool input before starting content block {index}"
)));
};
input.push_str(tool_delta.input());
Ok(Vec::new())
}
ContentBlockDelta::Citation(_) => Ok(Vec::new()),
ContentBlockDelta::Image(_) => {
Err(protocol_error("Bedrock emitted unsupported image output"))
}
ContentBlockDelta::ToolResult(_) => Err(protocol_error(
"Bedrock emitted an unexpected tool-result delta",
)),
_ => Err(protocol_error("Bedrock emitted an unknown content delta")),
}
}
AwsStreamEvent::ContentBlockStop(stop) => {
let index = stop.content_block_index();
let Some(block) = self.content_blocks.remove(&index) else {
return Ok(Vec::new());
};
match block {
PendingContentBlock::Tool { id, name, input } => {
let arguments = serde_json::from_str(&input).map_err(|error| {
protocol_error(format!(
"Bedrock returned invalid JSON for tool '{name}' ({id}): {error}"
))
})?;
Ok(vec![AgentEvent::Tool {
event: ToolEvent::Proposed {
call: ToolCall {
id,
name,
arguments,
},
},
}])
}
PendingContentBlock::Reasoning { text, signature } => {
Ok(vec![AgentEvent::ReasoningCompleted { text, signature }])
}
}
}
AwsStreamEvent::MessageStop(stop) => {
if self.stop_reason.is_some() {
return Err(protocol_error(
"Bedrock emitted more than one message-stop event",
));
}
self.stop_reason = Some(map_stop_reason(stop.stop_reason()));
Ok(Vec::new())
}
AwsStreamEvent::Metadata(metadata) => {
let Some(usage) = metadata.usage() else {
return Ok(Vec::new());
};
Ok(vec![AgentEvent::UsageUpdated {
usage: Usage {
input_tokens: nonnegative_tokens(usage.input_tokens()),
output_tokens: nonnegative_tokens(usage.output_tokens()),
cached_input_tokens: nonnegative_tokens(
usage.cache_read_input_tokens().unwrap_or(0),
),
cache_creation_input_tokens: nonnegative_tokens(
usage.cache_write_input_tokens().unwrap_or(0),
),
},
}])
}
_ => Err(protocol_error("Bedrock emitted an unknown stream event")),
}
}
fn finish(self) -> Result<Vec<AgentEvent>, AgentError> {
if !self.content_blocks.is_empty() {
return Err(protocol_error(
"Bedrock stream ended with incomplete content blocks",
));
}
let reason = self
.stop_reason
.ok_or_else(|| protocol_error("Bedrock stream ended before the message-stop event"))?;
Ok(vec![AgentEvent::TurnStopped { reason }])
}
}
#[derive(Debug)]
enum PendingContentBlock {
Tool {
id: String,
name: String,
input: String,
},
Reasoning {
text: String,
signature: Option<String>,
},
}
fn map_stop_reason(reason: &AwsStopReason) -> StopReason {
match reason {
AwsStopReason::EndTurn | AwsStopReason::StopSequence | AwsStopReason::ToolUse => {
StopReason::Completed
}
AwsStopReason::MaxTokens => StopReason::MaxTokens,
AwsStopReason::ModelContextWindowExceeded => StopReason::ContextWindowExceeded,
AwsStopReason::ContentFiltered | AwsStopReason::GuardrailIntervened => StopReason::Refusal,
AwsStopReason::MalformedModelOutput | AwsStopReason::MalformedToolUse => {
StopReason::Other(reason.as_str().to_string())
}
other => StopReason::Other(other.as_str().to_string()),
}
}
fn nonnegative_tokens(value: i32) -> u64 {
u64::try_from(value).unwrap_or_default()
}
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
Box::pin(futures::stream::iter([
Ok(AgentEvent::TurnStarted { runtime_request_id }),
Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
}),
]))
}
fn map_bedrock_error(error: impl std::fmt::Display + std::fmt::Debug) -> AgentError {
let display = error.to_string();
let debug = format!("{error:?}");
let message = if debug.len() > display.len() {
debug
} else {
display
};
let normalized = message.to_ascii_lowercase();
let kind = if normalized.contains("accessdenied")
|| normalized.contains("access denied")
|| normalized.contains("unauthorized")
|| normalized.contains("credential")
{
AgentErrorKind::Authentication
} else if normalized.contains("throttl") || normalized.contains("rate limit") {
AgentErrorKind::RateLimited
} else if normalized.contains("context window")
|| normalized.contains("too many tokens")
|| normalized.contains("modelcontextwindowexceeded")
{
AgentErrorKind::ContextWindowExceeded
} else if normalized.contains("validation")
|| normalized.contains("resource not found")
|| normalized.contains("resourcenotfound")
{
AgentErrorKind::InvalidRequest
} else if normalized.contains("timeout")
|| normalized.contains("dispatchfailure")
|| normalized.contains("connection")
{
AgentErrorKind::Transport
} else {
AgentErrorKind::Provider
};
let mut error = AgentError::new(kind, message);
error.recoverable = matches!(
kind,
AgentErrorKind::RateLimited | AgentErrorKind::Transport
);
error
}
fn protocol_error(message: impl Into<String>) -> AgentError {
AgentError::new(AgentErrorKind::Protocol, message)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
use aws_sdk_bedrockruntime::types::{
CacheTtl, ContentBlock, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart,
ContentBlockStartEvent, ContentBlockStopEvent, ConverseStreamMetadataEvent,
ConverseStreamOutput as AwsStreamEvent, MessageStopEvent, ReasoningContentBlockDelta,
StopReason as AwsStopReason, SystemContentBlock, TokenUsage, Tool, ToolUseBlockDelta,
ToolUseBlockStart,
};
use galaxy_agent_core::{
AgentErrorKind, AgentEvent, ConversationMessage, MessageContent, MessageRole, StopReason,
ToolDefinition, ToolEvent, TurnRequest, Usage,
};
use serde_json::json;
use super::*;
fn turn_request() -> TurnRequest {
let mut request = TurnRequest::new(
"anthropic.claude-test",
vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("first".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("response".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("continue".to_string()),
},
],
);
request.system_prompt = Some("system".to_string());
request.tools = vec![ToolDefinition {
name: "read_files".to_string(),
description: "Read files".to_string(),
input_schema: json!({"type": "object"}),
}];
request
}
fn cache_ttls(converted: &ConvertedRequest) -> Vec<Option<CacheTtl>> {
let mut ttls = Vec::new();
for message in &converted.messages {
for block in message.content() {
if let ContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
}
for block in &converted.system {
if let SystemContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
if let Some(tool_config) = &converted.tool_config {
for tool in tool_config.tools() {
if let Tool::CachePoint(point) = tool {
ttls.push(point.ttl().cloned());
}
}
}
ttls
}
#[test]
fn one_turn_transport_preserves_disabled_default_and_one_hour_cache_modes() {
let disabled = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert!(cache_ttls(&disabled).is_empty());
let default = convert_turn_request(turn_request(), Some(4096), CachingConfig::default());
assert_eq!(cache_ttls(&default), vec![None, None, None]);
let one_hour = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: true,
extended_ttl_requested: true,
},
);
assert_eq!(
cache_ttls(&one_hour),
vec![
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
]
);
}
#[test]
fn one_turn_transport_prefers_request_output_limit() {
let mut request = turn_request();
request.max_output_tokens = Some(8192);
let converted = convert_turn_request(request, Some(4096), CachingConfig::default());
assert_eq!(converted.inference_config.max_tokens(), Some(8192));
}
fn tool_start(index: i32, id: &str, name: &str) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.start(ContentBlockStart::ToolUse(
ToolUseBlockStart::builder()
.tool_use_id(id)
.name(name)
.build()
.unwrap(),
))
.build()
.unwrap(),
)
}
fn ordinary_start(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn content_delta(index: i32, delta: ContentBlockDelta) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockDelta(
ContentBlockDeltaEvent::builder()
.content_block_index(index)
.delta(delta)
.build()
.unwrap(),
)
}
fn content_stop(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStop(
ContentBlockStopEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn message_stop(reason: AwsStopReason) -> AwsStreamEvent {
AwsStreamEvent::MessageStop(
MessageStopEvent::builder()
.stop_reason(reason)
.build()
.unwrap(),
)
}
fn metadata(usage: Usage) -> AwsStreamEvent {
AwsStreamEvent::Metadata(
ConverseStreamMetadataEvent::builder()
.usage(
TokenUsage::builder()
.input_tokens(usage.input_tokens as i32)
.output_tokens(usage.output_tokens as i32)
.total_tokens((usage.input_tokens + usage.output_tokens) as i32)
.cache_read_input_tokens(usage.cached_input_tokens as i32)
.cache_write_input_tokens(usage.cache_creation_input_tokens as i32)
.build()
.unwrap(),
)
.build(),
)
}
#[test]
fn stream_translator_accepts_ordinary_content_block_starts() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator.translate(ordinary_start(0)).unwrap().is_empty());
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::Text("response".to_string()),
))
.unwrap(),
vec![AgentEvent::TextDelta {
text: "response".to_string(),
}]
);
assert!(translator.translate(content_stop(0)).unwrap().is_empty());
}
#[test]
fn stream_translator_correlates_tools_by_content_index() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(2, "call-2", "grep"))
.unwrap();
translator
.translate(tool_start(1, "call-1", "read_files"))
.unwrap();
translator
.translate(content_delta(
1,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"files\":[\"Cargo.toml\"]}")
.build()
.unwrap(),
),
))
.unwrap();
translator
.translate(content_delta(
2,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"query\":\"ProviderRun\"}")
.build()
.unwrap(),
),
))
.unwrap();
let first = translator.translate(content_stop(1)).unwrap();
let second = translator.translate(content_stop(2)).unwrap();
assert!(matches!(
first.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-1"
&& call.name == "read_files"
&& call.arguments == json!({"files": ["Cargo.toml"]})
));
assert!(matches!(
second.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-2"
&& call.name == "grep"
&& call.arguments == json!({"query": "ProviderRun"})
));
}
#[test]
fn stream_translator_defers_stop_until_usage_metadata_arrives() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator
.translate(message_stop(AwsStopReason::EndTurn))
.unwrap()
.is_empty());
let expected_usage = Usage {
input_tokens: 10,
output_tokens: 4,
cached_input_tokens: 7,
cache_creation_input_tokens: 3,
};
assert_eq!(
translator
.translate(metadata(expected_usage.clone()))
.unwrap(),
vec![AgentEvent::UsageUpdated {
usage: expected_usage,
}]
);
assert_eq!(
translator.finish().unwrap(),
vec![AgentEvent::TurnStopped {
reason: StopReason::Completed,
}]
);
}
#[test]
fn stream_translator_preserves_reasoning_text_and_signature() {
let mut translator = BedrockStreamTranslator::default();
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Text(
"inspect".to_string(),
)),
))
.unwrap(),
vec![AgentEvent::ReasoningDelta {
text: "inspect".to_string(),
}]
);
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Signature(
"signature".to_string(),
)),
))
.unwrap();
assert_eq!(
translator.translate(content_stop(0)).unwrap(),
vec![AgentEvent::ReasoningCompleted {
text: "inspect".to_string(),
signature: Some("signature".to_string()),
}]
);
}
#[test]
fn stream_translator_rejects_invalid_tool_json() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(0, "call", "read_files"))
.unwrap();
translator
.translate(content_delta(
0,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("not-json")
.build()
.unwrap(),
),
))
.unwrap();
let error = translator.translate(content_stop(0)).unwrap_err();
assert_eq!(error.kind, AgentErrorKind::Protocol);
}
#[test]
fn bedrock_stop_reasons_map_to_domain_reasons() {
assert_eq!(
map_stop_reason(&AwsStopReason::ToolUse),
StopReason::Completed
);
assert_eq!(
map_stop_reason(&AwsStopReason::MaxTokens),
StopReason::MaxTokens
);
assert_eq!(
map_stop_reason(&AwsStopReason::ModelContextWindowExceeded),
StopReason::ContextWindowExceeded
);
assert_eq!(
map_stop_reason(&AwsStopReason::GuardrailIntervened),
StopReason::Refusal
);
}
-197
View File
@@ -1,197 +0,0 @@
#![allow(dead_code)]
use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use crate::ai::agent::api::ResponseStream;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig, BedrockError};
use crate::ai::bedrock::convert::ConversationMessage;
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
use crate::ai::bedrock::request_translator;
pub struct TranslatorRequest {
pub config: BedrockClientConfig,
pub model_id: String,
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
pub bedrock_progressive_summary: Option<String>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>,
}
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<ResponseStream, BedrockError> {
let config = params.config.with_external_fallbacks();
let cross_region_inference = config.cross_region_inference;
let bedrock = BedrockClient::from_config(config).await?;
let task_id = params.root_task_id.unwrap_or_else(|| {
request
.task_context
.as_ref()
.and_then(|tc| tc.tasks.first())
.map(|t| t.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let needs_create_task = request
.task_context
.as_ref()
.map(|tc| tc.tasks.is_empty())
.unwrap_or(true);
// Use the model from params (selected in UI or defaulted from ANTHROPIC_MODEL)
let mut model_id = params.model_id;
if model_id.is_empty() || model_id == "auto" {
// Fall back to default if nothing is set
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
}
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
let diagnostic_logger =
BedrockDiagnosticLogger::try_new(&model_id, "", "", &task_id).map(Arc::new);
if let Some(ref logger) = diagnostic_logger {
logger.log_protobuf_input(request);
}
request_translator::inject_input_messages_into_task(request);
let new_input_messages = request_translator::extract_new_input_messages(request);
let new_input_count = new_input_messages.len();
let mut messages = Vec::new();
// Prepend progressive summary as the first message pair if present
if let Some(ref summary) = params.bedrock_progressive_summary {
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
summary
)),
});
messages.push(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
"Understood, I have the prior context. Continuing with the recent conversation."
.to_string(),
),
});
}
let history_len = params.bedrock_message_history.len();
messages.extend(params.bedrock_message_history);
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
history_len
);
messages.extend(new_input_messages);
}
for message in &mut messages {
message.truncate_tool_results_for_provider_request();
}
request_translator::sanitize_messages_for_bedrock(&mut messages);
let system_prompt = request_translator::extract_system_prompt(request, &params.global_rules);
let tools = request_translator::extract_tools(request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
messages.len(),
system_prompt.is_some(),
params.bedrock_progressive_summary.is_some(),
tools.len()
);
for (i, msg) in messages.iter().enumerate() {
let content_desc = describe_message_content(&msg.content);
log::info!(
"[bedrock] msg[{}]: role={:?}, content={}",
i,
msg.role,
content_desc
);
}
let user_query_text = request_translator::extract_user_query_text(request);
let stream = bedrock
.converse_stream(
&model_id,
&task_id,
needs_create_task,
messages.clone(),
system_prompt,
None, // progressive summary is in messages array, not system prompt
tools,
64000,
None,
cross_region_inference,
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
params.bedrock_tool_result_archive,
)
.await?;
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
// Only persist the actual conversation history (history + new inputs), not the
// ephemeral prepended summary pair, so we don't duplicate the summary on every
// subsequent write-back. The summary is prepended at request time each turn.
let persistent_count = history_len + new_input_count;
if persistent_count > 0 && messages.len() >= persistent_count {
*sent = messages.split_off(messages.len() - persistent_count);
} else {
*sent = messages;
}
}
Ok(stream)
}
fn describe_message_content(content: &crate::ai::bedrock::convert::MessageContent) -> String {
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
match content {
MessageContent::Text(t) => format!("Text({}chars)", t.len()),
MessageContent::ToolUse {
tool_use_id, name, ..
} => format!("ToolUse(name={}, id={})", name, tool_use_id),
MessageContent::ToolResult {
tool_use_id,
is_error,
..
} => format!("ToolResult(id={}, is_error={})", tool_use_id, is_error),
MessageContent::MultiPart(parts) => {
let part_descs: Vec<String> = parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => format!("Text({})", t.len()),
ContentPart::Image { data, mime_type } => {
format!("Image({mime_type},{}bytes)", data.len())
}
ContentPart::ToolUse {
name, tool_use_id, ..
} => format!("ToolUse({},{})", name, tool_use_id),
ContentPart::ToolResult { tool_use_id, .. } => {
format!("ToolResult({})", tool_use_id)
}
})
.collect();
format!("MultiPart[{}]", part_descs.join(", "))
}
}
}
File diff suppressed because it is too large Load Diff
+286 -62
View File
@@ -24,6 +24,7 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::collections::HashSet;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
@@ -73,6 +74,7 @@ use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentWaitPolicy,
};
pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor;
@@ -106,6 +108,27 @@ use crate::util::image::{
use crate::util::openable_file_type::is_binary_file;
use crate::BlocklistAIHistoryModel;
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
- Complete the assigned task directly and stay within its stated scope.
- Do not launch, delegate to, or create additional agents.
- Report blockers and completion to the lead through the available coordination channel."#;
pub(super) fn child_agent_delegation_denial_reason(
conversation_id: AIConversationId,
ctx: &AppContext,
) -> Option<String> {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_child_agent_conversation())
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
}
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
}
/// Types of actions that can be executed in parallel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ParallelExecutionPolicy {
@@ -209,12 +232,6 @@ pub enum NotExecutedReason {
WaitingOnSharer,
}
impl NotExecutedReason {
pub fn needs_confirmation(&self) -> bool {
matches!(self, Self::NeedsConfirmation)
}
}
/// Result type for `BlocklistAIActionExecutor::try_to_execute_action`.
#[derive(Debug)]
pub(super) enum TryExecuteResult {
@@ -229,9 +246,36 @@ pub(super) enum TryExecuteResult {
#[derive(Clone)]
struct AsyncExecutingAction {
action: AIAgentAction,
/// The conversation this action belongs to so cancellation and follow-up scheduling remain
/// scoped even when several conversations have async actions in flight.
conversation_id: AIConversationId,
}
type AsyncExecutingActionKey = (AIConversationId, AIAgentActionId);
#[derive(Default)]
struct AsyncExecutingActions(
std::collections::HashMap<AsyncExecutingActionKey, AsyncExecutingAction>,
);
impl AsyncExecutingActions {
fn insert(&mut self, conversation_id: AIConversationId, running: AsyncExecutingAction) {
self.0
.insert((conversation_id, running.action.id.clone()), running);
}
fn get(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AsyncExecutingAction> {
self.0.get(&(conversation_id, action_id.clone()))
}
fn remove(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<AsyncExecutingAction> {
self.0.remove(&(conversation_id, action_id.clone()))
}
}
impl AsyncExecutingAction {
@@ -270,10 +314,9 @@ pub struct BlocklistAIActionExecutor {
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID.
/// We track them per action rather than as a single slot so multiple actions from the same
/// parallel phase can complete independently.
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
/// The actions currently executing asynchronously, scoped by conversation and action ID.
async_executing_actions: AsyncExecutingActions,
restored_action_ids: HashSet<AsyncExecutingActionKey>,
/// Reference to the terminal model for checking session sharing state.
terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -334,8 +377,9 @@ impl BlocklistAIActionExecutor {
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
let run_agents_executor = ctx
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
let run_agents_executor = ctx.add_model(|ctx| {
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
let ask_user_question_executor =
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
@@ -360,6 +404,7 @@ impl BlocklistAIActionExecutor {
use_computer_executor,
request_computer_use_executor,
async_executing_actions: Default::default(),
restored_action_ids: Default::default(),
terminal_model,
read_skill_executor,
fetch_conversation_executor,
@@ -371,12 +416,46 @@ impl BlocklistAIActionExecutor {
}
}
pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> {
pub fn async_executing_action(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.async_executing_actions
.get(action_id)
.get(conversation_id, action_id)
.map(|running| &running.action)
}
pub fn mark_restored_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
self.restored_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
self.run_agents_executor.update(ctx, |executor, _| {
executor.mark_recovery_actions(conversation_id, action_ids);
});
}
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
self.async_executing_actions
.0
.iter()
.any(|((running_conversation_id, _), running)| {
*running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
}
/// Returns the action_id of any running WaitForEvents action for the
/// given conversation. There is at most one (wait_for_events is
/// documented as exclusive within a turn).
@@ -384,10 +463,9 @@ impl BlocklistAIActionExecutor {
&self,
conversation_id: AIConversationId,
) -> Option<AIAgentActionId> {
self.async_executing_actions
.iter()
.find_map(|(action_id, running)| {
if running.conversation_id == conversation_id
self.async_executing_actions.0.iter().find_map(
|((running_conversation_id, action_id), running)| {
if *running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::WaitForEvents { .. }
@@ -397,7 +475,8 @@ impl BlocklistAIActionExecutor {
} else {
None
}
})
},
)
}
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
@@ -602,8 +681,8 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool,
ctx: &mut ModelContext<Self>,
) -> TryExecuteResult {
log::info!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id,
std::mem::discriminant(&action.action),
is_user_initiated
@@ -611,7 +690,9 @@ impl BlocklistAIActionExecutor {
// We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() {
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode");
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: BLOCKED - shared session viewer mode"
);
return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action),
@@ -624,8 +705,8 @@ impl BlocklistAIActionExecutor {
};
let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute,
is_agent_autonomous
);
@@ -637,8 +718,8 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation {
log::info!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id
);
return TryExecuteResult::NotExecuted {
@@ -657,6 +738,7 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
@@ -672,11 +754,13 @@ impl BlocklistAIActionExecutor {
}
}
log::info!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id,
std::mem::discriminant(&action.action)
);
let action_key = (conversation_id, action.id.clone());
let is_restored = self.restored_action_ids.remove(&action_key);
let action_clone = action.clone();
let execution = match &action.action {
AIAgentActionType::RequestCommandOutput { .. }
@@ -828,8 +912,8 @@ impl BlocklistAIActionExecutor {
};
let action_id = action_clone.id.clone();
log::info!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution {
AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction",
@@ -840,8 +924,8 @@ impl BlocklistAIActionExecutor {
);
match execution {
AnyActionExecution::NotReady => {
log::info!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NOT READY - action_id={:?}",
action_id
);
TryExecuteResult::NotExecuted {
@@ -851,7 +935,7 @@ impl BlocklistAIActionExecutor {
}
AnyActionExecution::InvalidAction => {
log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
"try_to_execute_action: invalid action, action_id={:?}",
action_id
);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
@@ -865,24 +949,32 @@ impl BlocklistAIActionExecutor {
on_complete,
} => {
self.async_executing_actions.insert(
action_id.clone(),
conversation_id,
AsyncExecutingAction {
action: action_clone,
conversation_id,
},
);
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
});
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
if !is_restored {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
}
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: spawning ASYNC execution for action_id={:?}",
action_id
);
ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else {
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id);
let Some(running) = me
.async_executing_actions
.remove(conversation_id, &action_id)
else {
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
return;
};
let result = on_complete(result, ctx);
log::info!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id,
std::mem::discriminant(&result)
);
@@ -892,16 +984,19 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result,
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: None,
});
});
TryExecuteResult::ExecutedAsync
}
AnyActionExecution::Sync(action_result) => {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
});
if !is_restored {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
}
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
id: action_id,
@@ -933,6 +1028,7 @@ impl BlocklistAIActionExecutor {
pub fn cancel_running_async_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
@@ -941,13 +1037,42 @@ impl BlocklistAIActionExecutor {
if self.is_shared_session_viewer() {
return;
}
if let Some(running) = self.async_executing_actions.remove(action_id) {
if self
.async_executing_actions
.get(conversation_id, action_id)
.is_some_and(|running| {
matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
})
{
let termination_requested = self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(action_id, ctx)
});
if termination_requested {
// Keep the action in flight until block completion proves the process stopped.
// Its normal async completion will report the actual terminal exit status.
return;
}
}
if let Some(running) = self
.async_executing_actions
.remove(conversation_id, action_id)
{
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
crate::ai::tool_diagnostics::tool_debug!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
);
if running.is_shell_command_action() {
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Running action cancellation backtrace:\n{backtrace}");
}
if running.is_shell_command_action()
&& !matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
{
self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
@@ -957,7 +1082,11 @@ impl BlocklistAIActionExecutor {
});
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
executor.cancel_execution(conversation_id, &running.action.id, ctx);
});
} else if matches!(running.action.action, AIAgentActionType::StartAgent { .. }) {
self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_execution(conversation_id, &running.action.id);
});
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action
@@ -975,7 +1104,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result: running.action.action.cancelled_result(),
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: reason,
});
}
@@ -989,18 +1118,23 @@ impl BlocklistAIActionExecutor {
) {
let action_ids = self
.async_executing_actions
.0
.iter()
.filter_map(|(action_id, running)| {
(running.conversation_id == conversation_id).then_some(action_id.clone())
.filter_map(|((running_conversation_id, action_id), _)| {
(*running_conversation_id == conversation_id).then_some(action_id.clone())
})
.collect::<Vec<_>>();
for action_id in action_ids {
self.cancel_running_async_action(&action_id, reason, ctx);
self.cancel_running_async_action(conversation_id, &action_id, reason, ctx);
}
}
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
if cfg!(feature = "bedrock_smoke_test") {
if self
.restored_action_ids
.contains(&(input.conversation_id, input.action.id.clone()))
|| cfg!(feature = "bedrock_smoke_test")
{
return true;
}
match input.action.action {
@@ -1109,9 +1243,10 @@ impl Entity for BlocklistAIActionExecutor {
}
pub enum BlocklistAIActionExecutorEvent {
/// Emitted when an action is execution starts.
/// Emitted when an action begins execution.
ExecutingAction {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
},
/// Emitted when an action has finished.
@@ -1442,6 +1577,95 @@ async fn read_file_as_binary(file_path: &std::path::Path) -> Result<Vec<u8>, Fil
async_fs::read(file_path).await.map_err(FileLoadError::from)
}
#[cfg(test)]
mod async_executing_action_tests {
use super::*;
use crate::ai::agent::task::TaskId;
fn action(id: &str, task_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_owned()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new(task_id.to_owned()),
requires_result: true,
tool_name: Some("init_project".to_owned()),
}
}
#[test]
fn duplicate_action_ids_can_execute_concurrently_in_different_conversations() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
assert_eq!(running.0.len(), 2);
assert_eq!(
running
.get(first_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("first-task".to_owned())
);
assert_eq!(
running
.get(second_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("second-task".to_owned())
);
}
#[test]
fn duplicate_action_completion_and_cancellation_remove_only_the_matching_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
let completed = running.remove(first_conversation, &duplicate_id).unwrap();
assert_eq!(
completed.action.task_id,
TaskId::new("first-task".to_owned())
);
assert!(running.get(second_conversation, &duplicate_id).is_some());
let cancelled = running.remove(second_conversation, &duplicate_id).unwrap();
assert_eq!(
cancelled.action.task_id,
TaskId::new("second-task".to_owned())
);
assert!(running.0.is_empty());
}
}
#[cfg(all(test, feature = "local_fs"))]
#[path = "execute_tests.rs"]
mod tests;
@@ -83,6 +83,9 @@ fn initialize_ask_user_question_test(
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))]
{
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
..
} = input.action
else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
log::error!("CallMCPToolExecutor::execute: action type mismatch");
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
log::info!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name,
server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
);
let serde_json::Value::Object(mut arguments) = input.clone() else {
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
log::error!("CallMCPToolExecutor: input is not an object");
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
let Some(reconnecting_peer) = templatable_peer else {
log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
"CallMCPToolExecutor: MCP server for tool '{}' not found",
name_owned
);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned
);
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
tool_name: String,
ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType {
log::info!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
crate::ai::tool_diagnostics::tool_debug!(
"handle_call_tool_result: tool_name={}, is_ok={}",
tool_name,
res.is_ok()
);
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns,
path
);
@@ -237,8 +237,8 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"GrepExecutor::execute: queries={:?}, path={:?}",
queries,
path
);
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
crate::ai::tool_diagnostics::tool_debug!(
"ReadFilesExecutor::execute: {} files requested",
locations.len()
);
@@ -42,10 +42,34 @@ use crate::terminal::model::session::SessionType;
use crate::{safe_warn, BlocklistAIHistoryModel};
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
type AppliedDiffs = (Vec<FileDiff>, DiffSessionType);
#[derive(Default)]
struct PendingAppliedDiffs {
by_action: HashMap<AIAgentActionId, AppliedDiffs>,
}
impl PendingAppliedDiffs {
fn buffer(
&mut self,
action_id: AIAgentActionId,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
) {
self.by_action.insert(action_id, (diffs, diff_session_type));
}
fn take(&mut self, action_id: &AIAgentActionId) -> Option<AppliedDiffs> {
self.by_action.remove(action_id)
}
}
pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>,
apply_diff_model: ModelHandle<ApplyDiffModel>,
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
/// Successfully applied diffs that completed before their view was registered.
pending_applied_diffs: PendingAppliedDiffs,
/// Set of action IDs where diff application failed.
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
terminal_view_id: EntityId,
@@ -62,6 +86,7 @@ impl RequestFileEditsExecutor {
active_session,
apply_diff_model,
diff_views: HashMap::new(),
pending_applied_diffs: PendingAppliedDiffs::default(),
diff_application_failures: HashMap::new(),
terminal_view_id,
}
@@ -117,15 +142,18 @@ impl RequestFileEditsExecutor {
.is_allowed()
}
/// Registers a diff view to handle a RequestFileEdits action.
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in
/// order for the necessary state to be set to handle the action.
/// Registers a diff view to handle a RequestFileEdits action and applies any diffs that
/// finished preprocessing before the UI observed the action.
pub fn register_requested_edits(
&mut self,
action_id: &AIAgentActionId,
view: &ViewHandle<CodeDiffView>,
ctx: &mut ModelContext<Self>,
) {
self.diff_views.insert(action_id.clone(), view.clone());
if let Some((diffs, diff_session_type)) = self.pending_applied_diffs.take(action_id) {
Self::apply_diffs_to_view(view, diffs, diff_session_type, ctx);
}
}
pub(super) fn execute(
@@ -145,14 +173,14 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor::execute: action_id={:?}",
id
);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor: no diff view found for action_id={:?}",
id
);
return ActionExecution::NotReady;
@@ -322,23 +350,43 @@ impl RequestFileEditsExecutor {
tx: oneshot::Sender<()>,
ctx: &mut ModelContext<Self>,
) {
tx.send(()).ok();
match applied_diffs {
Ok(applied_diffs) if !applied_diffs.is_empty() => {
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let diffs = applied_diffs
.into_iter()
.map(|diff| {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
FileDiff::new(diff.original_content, path, diff.diff_type)
})
.collect();
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
let Some(diff_view) = self.diff_views.get(&id) else {
log::warn!(
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view"
);
return;
};
let applied_diffs = match applied_diffs {
Ok(diffs) if !diffs.is_empty() => diffs,
if let Some(diff_view) = self.diff_views.get(&id).cloned() {
Self::apply_diffs_to_view(&diff_view, diffs, diff_session_type, ctx);
} else {
self.pending_applied_diffs
.buffer(id, diffs, diff_session_type);
}
}
Ok(_) => {
// We didn't generate any diffs--consider this a failure.
log::warn!("No diffs generated");
self.diff_application_failures
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
return;
}
Err(err) => {
safe_warn!(
@@ -346,38 +394,18 @@ impl RequestFileEditsExecutor {
full: ("Failed to generate diffs {err:?}")
);
self.diff_application_failures.insert(id, err);
return;
}
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let mut diffs = Vec::with_capacity(applied_diffs.len());
for diff in applied_diffs {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
diffs.push(file_diff);
}
// Set the session type on the diff view so save/delete/create routes
// through the correct FileModel backend.
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
tx.send(()).ok();
}
fn apply_diffs_to_view(
diff_view: &ViewHandle<CodeDiffView>,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
ctx: &mut ModelContext<Self>,
) {
diff_view.update(ctx, |diff_view, ctx| {
diff_view.set_diff_session_type(diff_session_type);
diff_view.set_candidate_diffs(diffs, ctx);
@@ -2,8 +2,36 @@ use std::collections::HashMap;
use ai::agent::action_result::AnyFileContent;
use ai::agent::FileLocations;
use ai::diff_validation::DiffType;
use super::updated_file_contexts_from_editor_buffers;
use super::{
updated_file_contexts_from_editor_buffers, AIAgentActionId, DiffSessionType, FileDiff,
PendingAppliedDiffs,
};
#[test]
fn applied_diffs_survive_until_delayed_view_registration() {
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut pending = PendingAppliedDiffs::default();
pending.buffer(
action_id.clone(),
vec![FileDiff::new(
"before".to_string(),
"/workspace/src/main.rs".to_string(),
DiffType::update(vec![], None),
)],
DiffSessionType::Local,
);
let (diffs, session_type) = pending
.take(&action_id)
.expect("buffered diffs should remain available for registration");
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].base.content, "before");
assert_eq!(diffs[0].base.file_path, "/workspace/src/main.rs");
assert!(matches!(session_type, DiffSessionType::Local));
assert!(pending.take(&action_id).is_none());
}
#[test]
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
@@ -2,7 +2,7 @@
//!
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
//! and aggregates the outcomes into a single `RunAgentsResult`.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
@@ -12,15 +12,21 @@ use ai::agent::action_result::{
};
use ai::agent::orchestration_config::OrchestrationConfig;
use ai::skills::SkillReference;
use futures::future::BoxFuture;
use futures::future::{join_all, BoxFuture};
use futures::FutureExt;
use galaxy_core::execution_mode::AppExecutionMode;
use settings::Setting;
use warp_cli::agent::Harness;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::start_agent::{
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
StartAgentRequestId, StartAgentWaitPolicy,
};
use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
@@ -34,6 +40,8 @@ use crate::ai::document::plan_publication::{
prepare_plan_publications, wait_for_plan_publications,
};
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
/// Per-child spawn timeout. If a child agent doesn't report back within
/// this window (e.g. binary not found, server error), the slot is failed
@@ -60,7 +68,8 @@ struct ExistingLaunchedAgent {
}
pub struct RunAgentsExecutor {
pending: HashMap<AIAgentActionId, PendingRunAgents>,
pending: HashMap<(AIConversationId, AIAgentActionId), PendingRunAgents>,
recovery_action_ids: HashSet<(AIConversationId, AIAgentActionId)>,
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
@@ -69,12 +78,20 @@ pub struct RunAgentsExecutor {
/// Lifecycle events for in-flight dispatches.
pub enum RunAgentsExecutorEvent {
SpawningStarted {
conversation_id: AIConversationId,
action_id: AIAgentActionId,
snapshot: RunAgentsSpawningSnapshot,
},
SpawningFinished {
conversation_id: AIConversationId,
action_id: AIAgentActionId,
},
ChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
}
impl Entity for RunAgentsExecutor {
@@ -85,31 +102,77 @@ impl RunAgentsExecutor {
pub fn new(
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&start_agent_executor, |_, _, event, ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
ctx.emit(RunAgentsExecutorEvent::ChildConversationCreated {
action_id: action_id.clone(),
agent_name: agent_name.clone(),
parent_conversation_id: *parent_conversation_id,
child_conversation_id: *child_conversation_id,
});
}
});
Self {
pending: HashMap::new(),
recovery_action_ids: HashSet::new(),
launched_agents: HashMap::new(),
start_agent_executor,
terminal_view_id,
}
}
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
self.pending.contains_key(action_id)
pub fn is_pending(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> bool {
self.pending
.contains_key(&(conversation_id, action_id.clone()))
}
/// Cancels a pending run so publication completion cannot fan out children.
pub fn mark_recovery_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
) {
self.recovery_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
}
pub(crate) fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}
/// Cancels the parent tool wait without cancelling independently-running children.
pub(super) fn cancel_execution(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) {
if matches!(
self.pending.get(action_id),
Some(PendingRunAgents::Publishing)
) {
self.pending.remove(action_id);
let action_key = (conversation_id, action_id.clone());
self.recovery_action_ids.remove(&action_key);
let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_dispatches_for_action(conversation_id, action_id)
});
crate::ai::tool_diagnostics::tool_debug!(
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
);
if self.pending.remove(&action_key).is_some() {
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id,
action_id: action_id.clone(),
});
}
@@ -122,6 +185,22 @@ impl RunAgentsExecutor {
) {
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
self.launched_agents
.entry(conversation_id)
.or_default()
.insert(
normalized_name,
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
},
);
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -164,14 +243,39 @@ impl RunAgentsExecutor {
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.pending.contains_key(&action_id) {
let action_key = (parent_conversation_id, action_id.clone());
if self.pending.contains_key(&action_key) {
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents dispatch rejected",
serde_json::json!({
"event": "run_agents_dispatch_rejected",
"reason": "reentered_pending_action",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
}),
);
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
log::warn!("RunAgentsExecutor: validation failure: {error}");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents validation failed",
serde_json::json!({
"event": "run_agents_validation_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"error": remote_logging::sanitize_error(&error),
}),
);
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
@@ -181,8 +285,22 @@ impl RunAgentsExecutor {
agent_count: request.agent_run_configs.len(),
};
self.pending
.insert(action_id.clone(), PendingRunAgents::Publishing);
.insert(action_key, PendingRunAgents::Publishing);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents plan publication wait started",
serde_json::json!({
"event": "run_agents_plan_publication_wait_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": snapshot.agent_count,
"plan_id_present": !request.plan_id.trim().is_empty(),
}),
);
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(),
snapshot,
});
@@ -197,13 +315,14 @@ impl RunAgentsExecutor {
request
},
move |me, request, ctx| {
if !me.is_pending(&action_id_for_wait) {
if !me.is_pending(parent_conversation_id, &action_id_for_wait) {
return;
}
me.dispatch_children_for_prepared_request(
action_id_for_wait.clone(),
request,
parent_conversation_id,
HashMap::new(),
sender,
ctx,
)
@@ -213,16 +332,56 @@ impl RunAgentsExecutor {
receiver
}
fn dispatch_recovered_run_agents(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
recovery_children: HashMap<String, AIConversationId>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.is_pending(parent_conversation_id, &action_id) {
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
let snapshot = RunAgentsSpawningSnapshot {
agent_count: request.agent_run_configs.len(),
};
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(),
snapshot,
});
self.dispatch_children_for_prepared_request(
action_id,
request,
parent_conversation_id,
recovery_children,
sender,
ctx,
);
receiver
}
fn dispatch_children_for_prepared_request(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
mut recovery_children: HashMap<String, AIConversationId>,
sender: async_channel::Sender<RunAgentsResult>,
ctx: &mut ModelContext<Self>,
) {
self.pending
.insert(action_id.clone(), PendingRunAgents::Spawning);
self.pending.insert(
(parent_conversation_id, action_id.clone()),
PendingRunAgents::Spawning,
);
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|c| c.run_id());
@@ -238,8 +397,46 @@ impl RunAgentsExecutor {
..
} = request;
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch started",
serde_json::json!({
"event": "run_agents_child_dispatch_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": agent_run_configs.len(),
"execution_mode": run_agents_execution_mode_label(&run_execution_mode),
"harness_type": harness_type.as_str(),
"model_id_present": !model_id.trim().is_empty(),
"parent_run_id_present": parent_run_id.is_some(),
}),
);
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
let wait_policy = match &run_execution_mode {
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
};
for cfg in &agent_run_configs {
let normalized_name = normalize_agent_name(&cfg.name)
.expect("validated RunAgents requests have non-empty agent names");
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.reattach(
action_id.clone(),
cfg.name.clone(),
parent_conversation_id,
child_conversation_id,
wait_policy,
exec_ctx,
)
});
slots.push(ChildSlot::Pending(dispatch));
continue;
}
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
let mode = match run_agents_to_start_agent_mode(
&run_execution_mode,
@@ -251,6 +448,19 @@ impl RunAgentsExecutor {
) {
Ok(mode) => mode,
Err(err) => {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents child dispatch failed before launch",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": remote_logging::sanitize_error(&err),
}),
);
slots.push(ChildSlot::Failed(err));
continue;
}
@@ -258,13 +468,40 @@ impl RunAgentsExecutor {
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
&& parent_run_id.is_none()
{
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents remote child dispatch missing parent run_id",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": "Remote child agents require the parent run_id to be available.",
}),
);
slots.push(ChildSlot::Failed(
"Remote child agents require the parent run_id to be available.".to_string(),
));
continue;
}
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch queued",
serde_json::json!({
"event": "run_agents_child_dispatch_queued",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"execution_mode": start_agent_execution_mode_label(&mode),
}),
);
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.dispatch(
action_id.clone(),
cfg.name.clone(),
prompt,
mode,
@@ -274,7 +511,7 @@ impl RunAgentsExecutor {
exec_ctx,
)
});
slots.push(ChildSlot::Pending(recv));
slots.push(ChildSlot::Pending(dispatch));
}
let agent_run_configs_for_result = agent_run_configs.clone();
@@ -283,65 +520,95 @@ impl RunAgentsExecutor {
let run_harness_type = harness_type.clone();
let run_execution_mode_for_aggr = run_execution_mode.clone();
let parent_conversation_id_for_result = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let action_id_for_async_log = action_id.clone();
#[cfg(not(target_family = "wasm"))]
let parent_conversation_id_for_async_log = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let agent_names_for_async_log = agent_run_configs
.iter()
.map(|cfg| cfg.name.clone())
.collect::<Vec<_>>();
ctx.spawn(
async move {
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
for slot in slots {
let kind = match slot {
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error },
ChildSlot::Pending(recv) => {
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT);
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((
Ok(StartAgentOutcome::Started { agent_id }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Completed { agent_id, .. }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Error(error)),
_,
)) => RunAgentsAgentOutcomeKind::Failed { error },
futures::future::Either::Left((Err(_), _)) => {
RunAgentsAgentOutcomeKind::Failed {
error: "Cancelled before launch".to_string(),
}
}
futures::future::Either::Right((_, _)) => {
log::warn!(
"Agent spawn timed out after {} seconds",
SPAWN_TIMEOUT.as_secs()
);
RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
SPAWN_TIMEOUT.as_secs()
),
}
}
}
}
};
outcomes.push(kind);
let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
#[cfg(not(target_family = "wasm"))]
for (slot_index, resolved) in resolved_slots.iter().enumerate() {
log::info!(
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
agent_name={} slot_index={} outcome={}",
action_id_for_async_log,
parent_conversation_id_for_async_log,
agent_names_for_async_log
.get(slot_index)
.map(String::as_str)
.unwrap_or("<unknown>"),
slot_index,
run_agents_agent_outcome_kind_label(&resolved.outcome)
);
}
outcomes
resolved_slots
},
move |me, outcomes, ctx| {
move |me, resolved_slots, ctx| {
if !me.is_pending(parent_conversation_id_for_result, &action_id_for_aggr) {
return;
}
let timed_out_request_ids = resolved_slots
.iter()
.filter_map(|resolved| resolved.timed_out_request_id)
.collect::<Vec<_>>();
if !timed_out_request_ids.is_empty() {
me.start_agent_executor.update(ctx, |executor, _| {
for request_id in timed_out_request_ids {
executor.detach_dispatch(request_id);
}
});
}
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter()
.zip(outcomes)
.map(|(cfg, kind)| RunAgentsAgentOutcome {
.zip(resolved_slots)
.map(|(cfg, resolved)| RunAgentsAgentOutcome {
name: cfg.name.clone(),
kind,
kind: resolved.outcome,
})
.collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents launch outcomes resolved",
serde_json::json!({
"event": "run_agents_launch_outcomes_resolved",
"action_id": action_id_for_aggr.to_string(),
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
"agent_count": agents.len(),
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
"agents": agents
.iter()
.map(|agent| match &agent.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id } => serde_json::json!({
"name": agent.name.as_str(),
"status": "launched",
"agent_id": agent_id.as_str(),
}),
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
"name": agent.name.as_str(),
"status": "completed",
"agent_id": agent_id.as_str(),
"output": output,
}),
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name.as_str(),
"status": "failed",
"error": remote_logging::sanitize_error(error),
}),
})
.collect::<Vec<_>>(),
}),
);
let launched_mode = match &run_execution_mode_for_aggr {
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
RunAgentsExecutionMode::Remote {
@@ -360,8 +627,12 @@ impl RunAgentsExecutor {
execution_mode: launched_mode,
agents,
};
me.pending.remove(&action_id_for_aggr);
me.pending.remove(&(
parent_conversation_id_for_result,
action_id_for_aggr.clone(),
));
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id: parent_conversation_id_for_result,
action_id: action_id_for_aggr,
});
let _ = sender.try_send(result);
@@ -381,20 +652,58 @@ impl RunAgentsExecutor {
let mut request = request.clone();
let action_id = id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
let is_recovery = self
.recovery_action_ids
.remove(&(parent_conversation_id, action_id.clone()));
let receiver =
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
let recovery_children = if is_recovery {
prepare_recovery_request_for_execution(&mut request, parent_conversation_id, ctx);
match recovery_children_by_name(parent_conversation_id, ctx) {
Ok(children) => children,
Err(error) => {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Failure { error },
));
}
}
} else {
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents execution denied",
serde_json::json!({
"event": "run_agents_execution_denied",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"reason": remote_logging::sanitize_error(&reason),
}),
);
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
HashMap::new()
};
let receiver = if is_recovery {
self.dispatch_recovered_run_agents(
action_id,
request,
parent_conversation_id,
recovery_children,
ctx,
)
} else {
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx)
};
ActionExecution::new_async(
async move { receiver.recv().await },
@@ -413,6 +722,9 @@ impl RunAgentsExecutor {
let AIAgentActionType::RunAgents(request) = &input.action.action else {
return false;
};
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
return true;
}
if AppExecutionMode::as_ref(ctx).is_autonomous() {
return true;
}
@@ -444,9 +756,123 @@ impl RunAgentsExecutor {
#[path = "run_agents_tests.rs"]
mod tests;
#[cfg(not(target_family = "wasm"))]
fn log_run_agents_event(
ctx: &mut ModelContext<RunAgentsExecutor>,
level: RemoteLogLevel,
message: impl Into<String>,
context: serde_json::Value,
) {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level,
message: message.into(),
context,
},
);
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_execution_mode_label(mode: &RunAgentsExecutionMode) -> &'static str {
match mode {
RunAgentsExecutionMode::Local => "local",
RunAgentsExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static str {
match mode {
StartAgentExecutionMode::Local { .. } => "local",
StartAgentExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
match kind {
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
}
}
enum ChildSlot {
Failed(String),
Pending(async_channel::Receiver<StartAgentOutcome>),
Pending(StartAgentDispatch),
}
#[derive(Debug)]
struct ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind,
timed_out_request_id: Option<StartAgentRequestId>,
}
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
}
async fn resolve_child_slot_with_timeout(
slot: ChildSlot,
spawn_timeout: Duration,
) -> ResolvedChildSlot {
let dispatch = match slot {
ChildSlot::Failed(error) => {
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed { error },
timed_out_request_id: None,
};
}
ChildSlot::Pending(dispatch) => dispatch,
};
let request_id = dispatch.request_id;
let outcome = match dispatch.wait_policy {
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
StartAgentWaitPolicy::Startup => {
let timeout = warpui::r#async::Timer::after(spawn_timeout);
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((outcome, _)) => outcome.ok(),
futures::future::Either::Right((_, _)) => {
dispatch.mark_detached();
log::warn!(
"Agent spawn timed out after {} seconds",
spawn_timeout.as_secs()
);
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
spawn_timeout.as_secs()
),
},
timed_out_request_id: Some(request_id),
};
}
}
}
};
let outcome = match outcome {
Some(StartAgentOutcome::Started { agent_id }) => {
RunAgentsAgentOutcomeKind::Launched { agent_id }
}
Some(StartAgentOutcome::Completed { agent_id, output }) => {
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
}
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
None => RunAgentsAgentOutcomeKind::Failed {
error: "Child agent was cancelled before completion".to_string(),
},
};
ResolvedChildSlot {
outcome,
timed_out_request_id: None,
}
}
fn approved_orchestration_config_can_autoexecute(
@@ -476,9 +902,9 @@ fn resolve_request_from_approved_config(
/// Normalizes the request and returns a denial reason when launch is blocked.
///
/// Autonomous agents always run: their calls may still inherit approved plan
/// config fields and default auth secrets, but they bypass interactive policy
/// denials because they cannot present a confirmation card.
/// Root autonomous agents bypass interactive policy denials because they cannot
/// present a confirmation card. Child-agent delegation is rejected before that
/// bypass, while allowed root calls still inherit approved config and auth fields.
fn prepare_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
@@ -486,6 +912,11 @@ fn prepare_request_for_execution(
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return Some(reason);
}
normalize_request_for_local_execution(request);
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) =
@@ -521,6 +952,42 @@ fn prepare_request_for_execution(
None
}
fn prepare_recovery_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) {
normalize_request_for_local_execution(request);
resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
}
fn recovery_children_by_name(
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Result<HashMap<String, AIConversationId>, String> {
let mut children_by_name = HashMap::new();
for conversation in
BlocklistAIHistoryModel::as_ref(ctx).child_conversations_of(parent_conversation_id)
{
let Some(name) = conversation.agent_name() else {
continue;
};
let Some(normalized_name) = normalize_agent_name(name) else {
continue;
};
if children_by_name
.insert(normalized_name.clone(), conversation.id())
.is_some()
{
return Err(format!(
"Cannot recover child agent '{name}': multiple persisted child conversations have the same name."
));
}
}
Ok(children_by_name)
}
fn duplicate_launched_agents_reason(
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
@@ -544,8 +1011,11 @@ fn duplicate_launched_agents_reason(
let duplicates = requested_agents
.iter()
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Option<Vec<_>>>()?;
.filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Vec<_>>();
if duplicates.is_empty() {
return None;
}
let duplicate_list = duplicates
.iter()
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
@@ -590,6 +1060,19 @@ fn existing_launched_agents_for_conversation(
};
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
existing_agents.entry(normalized_name).or_insert_with(|| {
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
}
});
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -673,6 +1156,18 @@ fn populate_default_auth_secret_for_execution(
default_auth_secret_name_for_harness(&request.harness_type, ctx);
}
fn normalize_request_for_local_execution(request: &mut RunAgentsRequest) {
let edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
request.model_id = edit_state.model_id;
request.harness_type = edit_state.harness_type;
request.execution_mode = RunAgentsExecutionMode::Local;
request.harness_auth_secret_name = None;
}
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`.
@@ -696,6 +1191,23 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string());
}
if request.execution_mode.is_remote() {
return Err("Galaxy only supports local child-agent orchestration.".to_string());
}
let mut normalized_names = HashSet::new();
for config in &request.agent_run_configs {
let Some(normalized_name) = normalize_agent_name(&config.name) else {
return Err("orchestrate: agent names must not be empty".to_string());
};
if !normalized_names.insert(normalized_name) {
return Err(format!(
"orchestrate: duplicate agent name '{}' in the same batch",
config.name.trim()
));
}
}
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
if let Some(message) = local_harness_product_disabled_message(harness) {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{
@@ -91,6 +93,15 @@ fn persist_plan_config_with_harness(
});
}
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
});
}
#[test]
fn should_autoexecute_duplicate_launched_agent_denial() {
App::test((), |mut app| async move {
@@ -162,6 +173,522 @@ fn execute_denies_duplicate_launched_agent() {
});
}
#[test]
fn execute_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(
should_autoexecute,
"the denial should not require user approval"
);
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn execute_denies_mixed_batch_containing_launched_agent() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "new-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("child (agent-123)")
));
});
}
#[test]
fn validate_request_rejects_blank_and_duplicate_agent_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs[0].name = " ".to_string();
assert_eq!(
validate_request(&request),
Err("orchestrate: agent names must not be empty".to_string())
);
request.agent_run_configs[0].name = "Child".to_string();
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: " child ".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(
validate_request(&request),
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
);
}
#[test]
fn validate_request_allows_unique_sibling_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "second-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn local_normalization_clears_remote_only_fields_and_disabled_harness() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("codex").action else {
panic!("expected run_agents action");
};
request.model_id = "gpt-5".to_string();
request.harness_auth_secret_name = Some("remote-secret".to_string());
normalize_request_for_local_execution(&mut request);
assert!(matches!(
request.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(request.harness_type, "oz");
assert_eq!(request.model_id, "");
assert_eq!(request.harness_auth_secret_name, None);
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn validate_request_rejects_remote_dispatch() {
let AIAgentActionType::RunAgents(request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
assert_eq!(
validate_request(&request),
Err("Galaxy only supports local child-agent orchestration.".to_string())
);
}
#[test]
fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_child() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let existing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "missing-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
let missing_request = captured.read(&app, |captured, _| {
assert_eq!(captured.0.len(), 1);
assert_eq!(captured.0[0].name, "missing-child");
captured.0[0].clone()
});
history.update(&mut app, |history, ctx| {
history.update_conversation_status(
terminal_view_id,
existing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let missing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"missing-child".to_string(),
state.conversation_id,
None,
ctx,
)
});
history.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
missing_request.id,
missing_child_id,
ctx,
);
history.update_conversation_status(
terminal_view_id,
missing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched { agents, .. }) = result
else {
panic!("expected recovered RunAgents result");
};
assert_eq!(agents.len(), 2);
assert!(matches!(
&agents[0].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &existing_child_id.to_string()
));
assert!(matches!(
&agents[1].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &missing_child_id.to_string()
));
});
}
#[test]
fn cancelling_recovered_run_agents_keeps_persisted_child_running() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let action = remote_run_agents_action("oz");
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
state.executor.update(&mut app, |executor, ctx| {
executor.cancel_execution(state.conversation_id, &action.id, ctx);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled)
));
history.read(&app, |history, _| {
assert!(matches!(
history.conversation(&child_id).map(|child| child.status()),
Some(crate::ai::agent::conversation::ConversationStatus::InProgress)
));
});
});
}
#[test]
fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
App::test((), |_app| async move {
let (first_sender, first_receiver) = async_channel::bounded(1);
let (second_sender, second_receiver) = async_channel::bounded(1);
let slots = vec![
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver: first_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(2),
receiver: second_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Failed("prelaunch failure".to_string()),
];
let mut outcomes =
Box::pin(join_all(slots.into_iter().map(|slot| {
resolve_child_slot_with_timeout(slot, Duration::from_millis(1))
})));
second_sender
.try_send(StartAgentOutcome::Completed {
agent_id: "second-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(futures::poll!(&mut outcomes).is_pending());
assert!(
!second_sender.is_full(),
"join_all should poll and drain the second slot while the first is pending"
);
first_sender
.try_send(StartAgentOutcome::Error("first failed".to_string()))
.unwrap();
let outcomes = outcomes.await;
assert!(matches!(
&outcomes[0].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
));
assert!(matches!(
&outcomes[1].outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "second-agent" && output == "done"
));
assert!(matches!(
&outcomes[2].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
));
});
}
#[test]
fn completion_wait_ignores_spawn_timeout() {
App::test((), |_app| async move {
let (sender, receiver) = async_channel::bounded(1);
let completion = Box::pin(resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
));
let wait = warpui::r#async::Timer::after(Duration::from_millis(20));
let completion = match futures::future::select(completion, Box::pin(wait)).await {
futures::future::Either::Left((outcome, _)) => {
panic!("completion wait unexpectedly resolved before child completion: {outcome:?}")
}
futures::future::Either::Right((_, completion)) => completion,
};
sender
.try_send(StartAgentOutcome::Completed {
agent_id: "child-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(matches!(
completion.await.outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "child-agent" && output == "done"
));
});
}
#[test]
fn startup_wait_retains_spawn_timeout() {
App::test((), |_app| async move {
let (_sender, receiver) = async_channel::bounded(1);
let outcome = resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Startup,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
)
.await;
assert!(outcome.timed_out_request_id.is_some());
assert_eq!(
outcome.timed_out_request_id,
Some(StartAgentRequestId::from_raw_for_test(1))
);
assert!(matches!(
outcome.outcome,
RunAgentsAgentOutcomeKind::Failed { error }
if error.contains("Agent failed to start within")
));
});
}
#[test]
fn startup_timeout_detaches_exact_pending_request() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let start_agent_executor = state.start_agent_executor;
let parent_conversation_id = state.conversation_id;
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-timeout".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some("parent-run".to_string()),
ctx,
)
});
let request_id = dispatch.request_id;
let resolved =
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
.await;
let timed_out_request_id = resolved
.timed_out_request_id
.expect("startup timeout should expose request identity");
start_agent_executor.update(&mut app, |executor, _| {
assert!(executor.detach_dispatch(timed_out_request_id));
});
start_agent_executor.read(&app, |executor, _| {
assert!(!executor.has_pending_dispatch_for_test(request_id));
});
});
}
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -178,6 +705,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
@@ -190,8 +720,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let start_agent_executor = app.add_model(StartAgentExecutor::new);
let executor =
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
let executor = app.add_model(|ctx| {
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
RunAgentsTestState {
conversation_id,
@@ -321,7 +852,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() {
}
#[test]
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
fn approved_remote_plan_is_normalized_and_can_autoexecute_locally() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config_with_harness(
@@ -343,7 +874,7 @@ fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_sec
)
});
assert!(!should_autoexecute);
assert!(should_autoexecute);
});
}
@@ -561,9 +1092,9 @@ fn cancel_during_plan_publication_does_not_dispatch_children() {
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
assert!(matches!(execution, AnyActionExecution::Async { .. }));
state.executor.update(&mut app, |executor, ctx| {
assert!(executor.is_pending(&action_id));
executor.cancel_execution(&action_id, ctx);
assert!(!executor.is_pending(&action_id));
assert!(executor.is_pending(state.conversation_id, &action_id));
executor.cancel_execution(state.conversation_id, &action_id, ctx);
assert!(!executor.is_pending(state.conversation_id, &action_id));
});
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
@@ -617,7 +1148,7 @@ fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
}
#[test]
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
fn execute_normalizes_remote_non_oz_harness_without_requiring_remote_auth() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("codex");
@@ -634,21 +1165,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(
reason,
"Cloud child agents using this harness require an API key before they can run."
);
assert!(matches!(execution, AnyActionExecution::Async { .. }));
});
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() {
fn normalized_remote_non_oz_harness_autoexecutes_with_always_allow() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -669,7 +1191,7 @@ fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_def
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
fn normalized_remote_non_oz_harness_ignores_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -691,7 +1213,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
}
#[test]
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() {
fn normalized_remote_oz_harness_autoexecutes_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_util::path::ShellFamily;
use galaxyui::r#async::{Spawnable, Timer};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
event: &ModelEvent,
_ctx: &mut ModelContext<Self>,
) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
// evidence for shells that never deliver a subsequent precmd.
if matches!(
event,
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
| ModelEvent::BlockCompleted(_)
) {
let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
if let Some(block) = block_selector.get_block(&model) {
if block.is_command_finished() {
let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
for (block_selector, block_finished_txs) in block_finished_senders {
let completed_block = block_selector.get_block(&model).filter(|block| {
block.is_command_finished()
&& match event {
ModelEvent::BlockCompleted(completed) => {
block.id() == &completed.block_id
}
ModelEvent::BlockMetadataReceived(_) => true,
_ => false,
}
});
if completed_block.is_some() {
for block_finished_tx in block_finished_txs {
if let Err(e) = block_finished_tx.send(()) {
log::warn!(
"Failed to notify block completion for running requested command: {e:?}"
)
}
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
}
} else {
// The requested-command association may not exist yet. Keep all waiters until
// this selector resolves and its block actually completes, or it is cancelled.
self.block_finished_senders
.insert(block_selector, block_finished_txs);
}
}
}
@@ -190,30 +204,13 @@ impl ShellCommandExecutor {
}
}
/// Decorate the command so that we can turn off pager.
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
match self.active_session.as_ref(ctx).shell_type(ctx) {
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
// avoid cases with aliases.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
// gets evaluated first.
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
// For powershell, we use Out-Host to send paged output to the
// console. Add a backslash to avoid executing an alias.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
// If we can't determine a shell type, run command as it is.
None => command.clone(),
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
log::info!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action)
);
let model = self.terminal_model.lock();
@@ -221,17 +218,10 @@ impl ShellCommandExecutor {
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle();
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
uses_pager,
wait_until_completion,
..
} => {
@@ -240,18 +230,13 @@ impl ShellCommandExecutor {
.active_block()
.is_active_and_long_running()
{
// Another command is still running (e.g. stuck in a pager). Return an error
// result so the model receives feedback and can adapt. Using Completed with a
// non-zero exit code ensures a follow-up request is triggered.
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed {
command: command.clone(),
block_id: model.block_list().active_block().id().clone(),
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
exit_code: ExitCode::from(1),
start_ts: None,
completed_ts: None,
},
let running_command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false);
return ActionExecution::Sync(terminal_busy_execution_error(
command,
&running_command,
));
}
// If another conversation has taken over the agent view since this command
@@ -266,15 +251,13 @@ impl ShellCommandExecutor {
RequestCommandOutputResult::CancelledBeforeExecution,
));
}
// If the command might use pager and can't be interacted with,
// we pipe its output to cat so we can prevent activating the altscreen.
// The parentheses here ensures the command always gets evaluated first.
let decorated_command =
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion {
self.turn_off_pager_for_command(command, ctx)
} else {
command.clone()
};
// A command expected to finish must not enter an implicit pager. Do not trust the
// model-provided pager hint: commands such as `git log` can page implicitly.
let decorated_command = command_for_execution(
command,
self.active_session.as_ref(ctx).shell_type(ctx),
*wait_until_completion,
);
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(),
command: decorated_command,
@@ -295,8 +278,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -359,8 +341,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -391,6 +372,7 @@ impl ShellCommandExecutor {
},
));
}
let command = block.command_with_secrets_unobfuscated(false);
drop(model);
let block_selector = BlockSelector::Id(block_id.clone());
@@ -400,8 +382,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -439,7 +420,9 @@ impl ShellCommandExecutor {
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx);
.entry(block_selector.clone())
.or_default()
.push(block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
@@ -511,7 +494,7 @@ impl ShellCommandExecutor {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
me.control_handback_sender = None;
});
}
@@ -540,13 +523,17 @@ impl ShellCommandExecutor {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
.entry(block_selector.clone())
.or_default()
.push(block_metadata_received_tx);
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
.entry(block_selector.clone())
.or_default()
.push(force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
@@ -620,7 +607,12 @@ impl ShellCommandExecutor {
completed_ts: block.completed_ts().cloned(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
model.is_alt_screen_active(),
model.active_block_id(),
block.id(),
);
let grid_contents = if selected_block_owns_alt_screen {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
@@ -638,7 +630,7 @@ impl ShellCommandExecutor {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted,
}
}
@@ -650,23 +642,50 @@ impl ShellCommandExecutor {
}
}
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
pub(super) fn cancel_execution(
&mut self,
id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> bool {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return;
}
let selector = if active_block
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
let requested_selector = BlockSelector::RequestedCommandId(id.clone());
let requested_block_is_running = requested_selector
.get_block(&terminal_model)
.is_some_and(|block| block.is_active_and_long_running() && !block.finished());
let selector = if requested_block_is_running {
requested_selector
} else {
BlockSelector::Id(active_block.id().clone())
BlockSelector::Id(terminal_model.active_block_id().clone())
};
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
// Cancelling the wait future alone would report cancellation while the process keeps
// running. Terminate the exact requested command before resolving the action as cancelled.
if requested_block_is_running {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
}
if !requested_block_is_running {
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
requested_block_is_running
}
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
}
fn prune_closed_sender_group(
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
selector: &BlockSelector,
) {
if let Some(selector_senders) = senders.get_mut(selector) {
selector_senders.retain(|sender| !sender.is_canceled());
if selector_senders.is_empty() {
senders.remove(selector);
}
}
}
/// Force any in-flight poll for the given long-running command block to resolve
@@ -677,23 +696,28 @@ impl ShellCommandExecutor {
/// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
// at a time.
// Find every pending poll whose selector resolves to this block. Multiple provider polls
// may legitimately wait on the same command and must be refreshed together.
let matching_selector = self
.force_refresh_senders
.keys()
.find(|selector| {
selector
.get_block(&terminal_model)
.is_some_and(|block| block.id() == block_id)
selector.get_block(&terminal_model).is_some_and(|block| {
block.id() == block_id
&& block.is_active_and_long_running()
&& !block.finished()
})
})
.cloned();
drop(terminal_model);
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
return sender.send(()).is_ok();
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
let mut refreshed = false;
for sender in senders {
refreshed |= sender.send(()).is_ok();
}
return refreshed;
}
}
false
@@ -708,6 +732,45 @@ impl ShellCommandExecutor {
}
}
fn command_for_execution(
command: &str,
shell_type: Option<ShellType>,
wait_until_completion: bool,
) -> String {
if !wait_until_completion {
return command.to_string();
}
match shell_type {
// Pager environment variables preserve the command's output and exit status, unlike piping
// through `cat`. Tool-specific variables override user configuration for common pagers.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!(
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; {command})"
),
Some(ShellType::Fish) => format!(
"begin; set -lx PAGER cat; set -lx GIT_PAGER cat; set -lx GH_PAGER cat; set -lx AWS_PAGER cat; set -lx SYSTEMD_PAGER cat; {command}; end"
),
// PowerShell's pipeline host suppresses paging for commands that honor the host stream.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
None => command.to_string(),
}
}
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
command: command.to_string(),
message: format!("terminal is busy running command '{running_command}'"),
})
}
fn selected_block_owns_alt_screen(
is_alt_screen_active: bool,
active_block_id: &BlockId,
selected_block_id: &BlockId,
) -> bool {
is_alt_screen_active && active_block_id == selected_block_id
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector {
Id(BlockId),
@@ -913,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
input: Bytes,
mode: AIAgentPtyWriteMode,
},
CancelExecution,
CancelExecution {
action_id: AIAgentActionId,
},
/// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser {
action_id: AIAgentActionId,
@@ -1,18 +1,80 @@
use std::sync::Arc;
use std::task::Poll;
use async_channel::unbounded;
use futures::channel::oneshot;
use futures::{pin_mut, poll};
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{ActionResult, BlockSelector, ShellCommandExecutor};
use crate::ai::agent::ShellCommandDelay;
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
use super::{
command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
ActionResult, BlockSelector, ShellCommandExecutor,
};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
};
use crate::terminal::event::{
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::AIConversationId;
#[test]
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
let command = "git log -8 --oneline && false";
let decorated = command_for_execution(command, Some(ShellType::Zsh), true);
assert_eq!(
decorated,
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; git log -8 --oneline && false)"
);
assert!(!decorated.contains("| command cat"));
assert_eq!(
command_for_execution(command, Some(ShellType::Zsh), false),
command
);
}
#[test]
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
let result = terminal_busy_execution_error("cargo test", "sleep 120");
assert!(matches!(
result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { command, message }
) if command == "cargo test"
&& message == "terminal is busy running command 'sleep 120'"
));
}
#[test]
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
let active_block_id = BlockId::new();
let selected_block_id = BlockId::new();
assert!(!selected_block_owns_alt_screen(
true,
&active_block_id,
&selected_block_id
));
assert!(selected_block_owns_alt_screen(
true,
&active_block_id,
&active_block_id
));
assert!(!selected_block_owns_alt_screen(
false,
&active_block_id,
&active_block_id
));
}
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
@@ -46,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
let selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx);
executor.block_finished_senders.insert(selector, vec![tx]);
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
@@ -71,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
that map is reserved for precmd (BlockMetadataReceived)"
);
// Precmd event — the senders map should be drained (and since the
// block isn't in the terminal model, the sender is dropped).
// An unrelated precmd cannot resolve this selector, so its waiter must survive.
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
@@ -85,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0,
"BlockMetadataReceived should drain the finish senders"
1,
"BlockMetadataReceived must retain unresolved finish senders"
);
});
}
@@ -103,11 +164,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 120", "still running");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
@@ -118,15 +182,170 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
assert!(executor.force_refresh_block(&block_id));
assert!(!executor.force_refresh_block(&block_id));
});
assert!(matches!(rx.try_recv(), Ok(Some(()))));
let (tx, _rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
!executor.force_refresh_block(&block_id)
}));
});
}
#[test]
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let action_id = AIAgentActionId::from("requested-command".to_string());
let result_future = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::RequestedCommandId(action_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(result_future);
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert!(matches!(poll!(&mut result_future), Poll::Pending));
terminal_model
.lock()
.simulate_long_running_block("printf done", "done");
let block_id = terminal_model.lock().active_block_id().clone();
terminal_model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_requested_command(
action_id,
None,
AIConversationId::new(),
);
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
assert!(matches!(
result_future.await,
ActionResult::CommandFinished {
block_id: result_block_id,
..
} if result_block_id == block_id
));
});
}
#[test]
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 1", "finished");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let first = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
let second = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(first);
pin_mut!(second);
assert!(matches!(poll!(&mut first), Poll::Pending));
assert!(matches!(poll!(&mut second), Poll::Pending));
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
let first_result = first.await;
let second_result = second.await;
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
assert!(matches!(
second_result,
ActionResult::CommandFinished { .. }
));
});
}
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
BlockCompletedEvent {
block_latency_data: None,
block_type: BlockType::Restored,
num_secrets_obfuscated: 0,
block_index: BlockIndex::zero(),
block_id,
session_id: None,
restored_block_was_local: None,
}
}
#[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures::future::BoxFuture;
use futures::FutureExt;
@@ -6,7 +8,10 @@ use galaxy_cli::agent::Harness;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::{
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
@@ -27,10 +32,38 @@ pub enum StartAgentOutcome {
agent_id: String,
output: String,
},
/// An error occurred while starting the agent.
/// An error occurred while starting or running the agent.
Error(String),
}
/// Determines whether a dispatch receiver acknowledges startup or waits for a
/// direct-provider child to reach a terminal state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartAgentWaitPolicy {
Startup,
Completion,
}
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
match mode {
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
}
}
pub struct StartAgentDispatch {
pub request_id: StartAgentRequestId,
pub receiver: async_channel::Receiver<StartAgentOutcome>,
pub wait_policy: StartAgentWaitPolicy,
pub(super) detached: Arc<AtomicBool>,
}
impl StartAgentDispatch {
pub(super) fn mark_detached(&self) {
self.detached.store(true, Ordering::Release);
}
}
fn invalid_local_child_harness_error(harness_type: &str) -> String {
let harness_name = harness_type.trim();
if harness_name.is_empty() {
@@ -115,17 +148,19 @@ pub struct StartAgentRequest {
}
struct PendingStartAgent {
/// Present for standalone StartAgent tool calls. RunAgents dispatches use
/// the same executor but do not have a one-to-one StartAgent action card.
action_id: Option<AIAgentActionId>,
action_id: AIAgentActionId,
/// Present when RunAgents owns this dispatch. Standalone StartAgent calls
/// use the action id only for their one-to-one inline child panel.
run_agents_child_name: Option<String>,
parent_conversation_id: AIConversationId,
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>,
detached: Arc<AtomicBool>,
/// Direct Bedrock/OpenAI parents do not have a server run id or an
/// orchestration event stream. Keep the tool call open until their local
/// child finishes, then return the child's output inline.
wait_for_completion: bool,
wait_policy: StartAgentWaitPolicy,
}
pub struct StartAgentExecutor {
@@ -158,34 +193,41 @@ impl StartAgentExecutor {
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let direct_provider_panel_link = {
let Some(pending) = self.pending.get_mut(&request_id) else {
let child_link_event = {
let Some(pending) = self.pending.get(&request_id) else {
return;
};
if pending.detached.load(Ordering::Acquire) {
self.pending.remove(&request_id);
return;
}
let pending = self
.pending
.get_mut(&request_id)
.expect("pending request was checked above");
pending.child_conversation_id = Some(child_conversation_id);
if pending.wait_for_completion {
pending.action_id.clone().map(|action_id| {
(
action_id,
pending.parent_conversation_id,
child_conversation_id,
)
if let Some(agent_name) = pending.run_agents_child_name.clone() {
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id: pending.action_id.clone(),
agent_name,
parent_conversation_id: pending.parent_conversation_id,
child_conversation_id,
})
} else if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
Some(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id: pending.action_id.clone(),
parent_conversation_id: pending.parent_conversation_id,
child_conversation_id,
},
)
} else {
None
}
};
if let Some((action_id, parent_conversation_id, child_conversation_id)) =
direct_provider_panel_link
{
ctx.emit(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
},
);
if let Some(event) = child_link_event {
ctx.emit(event);
}
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
}
@@ -271,14 +313,15 @@ impl StartAgentExecutor {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
// A child that reaches `complete_pending_as_error` never obtained an
// agent id, so it failed at the launch stage. Clean up its hidden
// pane + conversation so the orchestration pill bar does not retain a
// dead chip — but only for terminal failures, leaving recoverable
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
// Only startup acknowledgements may clean up a conversation that never
// initialized. Direct-provider completion waits preserve the terminal
// child so its transcript and failure remain inspectable.
let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup)
&& BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.is_some_and(|conversation| {
should_cleanup_failed_child_launch(conversation.status())
});
if should_cleanup {
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
conversation_id: child_conversation_id,
@@ -297,23 +340,49 @@ impl StartAgentExecutor {
else {
return;
};
if let Some(error_msg) = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
return;
}
let wait_for_completion = self
let wait_policy = self
.pending
.get(&request_id)
.is_some_and(|pending| pending.wait_for_completion);
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
return;
}
if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
.map(|pending| pending.wait_policy);
match wait_policy {
Some(StartAgentWaitPolicy::Completion) => match conversation.status() {
ConversationStatus::Success => {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
}
ConversationStatus::Error | ConversationStatus::Cancelled => {
let error_msg = direct_child_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
)
.expect("terminal direct child status should produce an error");
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
}
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => {}
},
Some(StartAgentWaitPolicy::Startup) => {
if let Some(error_msg) = start_agent_startup_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
} else if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
}
}
None => {}
}
}
@@ -346,6 +415,22 @@ impl StartAgentExecutor {
} => {
self.record_child_conversation(*request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(
"Child agent conversation was removed by the user.".to_string(),
));
}
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
@@ -358,8 +443,6 @@ impl StartAgentExecutor {
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
@@ -400,12 +483,19 @@ impl StartAgentExecutor {
return ActionExecution::InvalidAction;
};
let prompt = prompt.clone();
let version = *version;
let action_id = input.action.id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, version },
));
}
let prompt = prompt.clone();
let action_id = input.action.id.clone();
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
let prompt = compose_leaf_agent_prompt(&prompt);
let (execution_mode, parent_run_id) = match execution_mode {
StartAgentExecutionMode::Local {
harness_type: None,
@@ -531,20 +621,23 @@ impl StartAgentExecutor {
}
};
// In local mode (no parent_run_id), block until the child finishes
// so the parent model receives the child's output as the tool result.
let wait_for_completion = parent_run_id.is_none();
// Local children return their completed work; remote children acknowledge startup and
// continue through the hosted orchestration lifecycle.
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
action_id: Some(action_id),
action_id,
run_agents_child_name: None,
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion,
detached,
wait_policy,
},
);
@@ -589,6 +682,7 @@ impl StartAgentExecutor {
#[allow(clippy::too_many_arguments)]
pub fn dispatch(
&mut self,
action_id: AIAgentActionId,
name: String,
prompt: String,
execution_mode: StartAgentExecutionMode,
@@ -596,19 +690,34 @@ impl StartAgentExecutor {
parent_conversation_id: AIConversationId,
parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> {
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
) -> StartAgentDispatch {
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error));
return StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
};
}
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let prompt = compose_leaf_agent_prompt(&prompt);
self.pending.insert(
request_id,
PendingStartAgent {
action_id: None,
action_id,
run_agents_child_name: Some(name.clone()),
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion: parent_run_id.is_none(),
detached: detached.clone(),
wait_policy,
},
);
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
@@ -622,7 +731,92 @@ impl StartAgentExecutor {
parent_run_id,
},
)));
receiver
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
pub fn reattach(
&mut self,
action_id: AIAgentActionId,
name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
wait_policy: StartAgentWaitPolicy,
ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch {
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
action_id,
run_agents_child_name: Some(name),
parent_conversation_id,
child_conversation_id: Some(child_conversation_id),
sender,
detached: detached.clone(),
wait_policy,
},
);
self.record_child_conversation(request_id, child_conversation_id, ctx);
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
/// Detaches one exact dispatch. If its launch callback is already queued,
/// the shared marker prevents that callback from linking a late child.
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
let Some(pending) = self.pending.remove(&request_id) else {
return false;
};
pending.detached.store(true, Ordering::Release);
true
}
/// Test-only lookup for request ownership without exposing executor internals.
#[cfg(test)]
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
self.pending.contains_key(&request_id)
}
pub fn cancel_dispatches_for_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> usize {
let request_ids = self
.pending
.iter()
.filter_map(|(request_id, pending)| {
(pending.parent_conversation_id == conversation_id
&& &pending.action_id == action_id)
.then_some(*request_id)
})
.collect::<Vec<_>>();
let detached_count = request_ids.len();
for request_id in request_ids {
self.detach_dispatch(request_id);
}
detached_count
}
/// Cancels only the caller's pending tool wait. A child that was already created keeps
/// running independently and remains available in conversation history.
pub(super) fn cancel_execution(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
self.cancel_dispatches_for_action(conversation_id, action_id);
}
pub(super) fn preprocess_action(
@@ -666,7 +860,7 @@ fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
}
}
fn start_agent_error_message_for_status(
fn start_agent_startup_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
@@ -701,6 +895,26 @@ fn start_agent_error_message_for_status(
}
}
fn direct_child_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
match status {
ConversationStatus::Error => Some(
error_message
.filter(|message| !message.trim().is_empty())
.unwrap_or("Child agent failed")
.to_string(),
),
ConversationStatus::Cancelled => Some("Child agent was cancelled by the user.".to_string()),
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Success
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => None,
}
}
impl Entity for StartAgentExecutor {
type Event = StartAgentExecutorEvent;
}
@@ -715,6 +929,14 @@ pub enum StartAgentExecutorEvent {
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A RunAgents child conversation is available for live status and
/// navigation in the owning action card.
RunAgentsChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A child agent failed at the launch stage (never started a server-side
/// run). The owning terminal view removes its hidden pane and conversation
/// so the orchestration pill bar does not retain a dead chip.
@@ -28,6 +28,37 @@ impl Entity for CapturedDirectProviderChildLinks {
type Event = ();
}
#[derive(Default)]
struct CapturedStartAgentPrompts(Vec<String>);
impl Entity for CapturedStartAgentPrompts {
type Event = ();
}
#[derive(Default)]
struct CapturedRunAgentsChildLinks(
Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>,
);
impl Entity for CapturedRunAgentsChildLinks {
type Event = ();
}
fn capture_start_agent_prompts(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedStartAgentPrompts> {
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CreateAgent(request) = event {
captured.0.push(request.prompt.clone());
}
});
});
captured
}
fn capture_direct_provider_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
@@ -52,6 +83,32 @@ fn capture_direct_provider_child_links(
captured
}
fn capture_run_agents_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedRunAgentsChildLinks> {
let captured = app.add_model(|_| CapturedRunAgentsChildLinks::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
captured.0.push((
action_id.clone(),
agent_name.clone(),
*parent_conversation_id,
*child_conversation_id,
));
}
});
});
captured
}
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
@@ -79,6 +136,192 @@ fn build_start_agent_action_with_prompt(
}
}
#[test]
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_start_agent_prompts(&mut app, &executor);
let root_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: root_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
captured.read(&app, |captured, _ctx| {
assert_eq!(captured.0.len(), 1);
assert!(captured.0[0].contains("You are a leaf worker"));
assert!(
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
);
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
});
});
}
#[test]
fn execute_denies_start_agent_from_child_conversation() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: child_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, .. }
)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn dispatch_denies_child_conversation_defense_in_depth() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-action".to_string()),
"grandchild".to_string(),
"Do more work".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
child_conversation_id,
None,
ctx,
)
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn local_execution_waits_for_completion() {
assert_eq!(
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
StartAgentWaitPolicy::Completion
);
}
#[test]
fn detach_dispatch_rejects_late_child_callback() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some(PARENT_RUN_ID.to_string()),
ctx,
)
});
assert!(executor.update(&mut app, |executor, _| {
executor.detach_dispatch(dispatch.request_id)
}));
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
dispatch.request_id,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
assert!(dispatch.receiver.try_recv().is_err());
});
}
#[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
@@ -543,6 +786,426 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
});
}
struct PendingDirectProviderChild {
action_id: AIAgentActionId,
parent_conversation_id: AIConversationId,
history_model: ModelHandle<BlocklistAIHistoryModel>,
executor: ModelHandle<StartAgentExecutor>,
captured_cleanup: ModelHandle<CapturedCleanupEvents>,
direct_links: ModelHandle<CapturedDirectProviderChildLinks>,
run_agents_links: ModelHandle<CapturedRunAgentsChildLinks>,
terminal_view_id: EntityId,
child_conversation_id: AIConversationId,
dispatch: StartAgentDispatch,
}
fn dispatch_pending_direct_provider_child(app: &mut App) -> PendingDirectProviderChild {
initialize_history_persistence_for_tests(app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured_cleanup = app.add_model(|_| CapturedCleanupEvents::default());
captured_cleanup.update(app, |_, ctx| {
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
captured.0.push(*conversation_id);
}
});
});
let direct_links = capture_direct_provider_child_links(app, &executor);
let run_agents_links = capture_run_agents_child_links(app, &executor);
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(app, |executor, ctx| {
executor.dispatch(
action_id.clone(),
"child".to_string(),
"Investigate the failure".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
parent_conversation_id,
None,
ctx,
)
});
let child_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(app, |history_model, ctx| {
history_model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
PendingDirectProviderChild {
action_id,
parent_conversation_id,
history_model,
executor,
captured_cleanup,
direct_links,
run_agents_links,
terminal_view_id,
child_conversation_id,
dispatch,
}
}
#[test]
fn direct_provider_nonterminal_states_remain_pending_until_cancelled() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
assert_eq!(state.dispatch.wait_policy, StartAgentWaitPolicy::Completion);
for status in [
ConversationStatus::Blocked {
blocked_action: "Waiting for user input".to_string(),
},
ConversationStatus::TransientError,
ConversationStatus::WaitingForEvents,
] {
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
status,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
});
}
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Cancelled,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent was cancelled by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
});
}
#[test]
fn direct_provider_error_preserves_child_for_inspection() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other("Child execution failed", false)),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed"
));
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.history_model.read(&app, |history_model, _| {
assert!(history_model
.conversation(&state.child_conversation_id)
.is_some());
});
});
}
#[test]
fn cancelling_standalone_start_agent_drops_dispatch_but_keeps_child_running() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
},
ctx,
)
.into()
});
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
executor.update(&mut app, |executor, _| {
executor.cancel_execution(parent_conversation_id, &action.id);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
history_model.read(&app, |history, _| {
assert_eq!(
history
.conversation(&child_conversation_id)
.expect("child should remain in history")
.status(),
&ConversationStatus::InProgress
);
});
let AnyActionExecution::Async { execute_future, .. } = execution else {
panic!("expected async StartAgent execution");
};
let _ = execute_future.await;
});
}
#[test]
fn cancelling_duplicate_action_id_detaches_only_the_matching_conversation() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let terminal_view_id = EntityId::new();
let first_conversation = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let second_conversation = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let first = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: first_conversation,
},
ctx,
)
.into()
});
let second = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: second_conversation,
},
ctx,
)
.into()
});
assert!(matches!(first, AnyActionExecution::Async { .. }));
assert!(matches!(second, AnyActionExecution::Async { .. }));
executor.update(&mut app, |executor, _| {
executor.cancel_execution(first_conversation, &action.id);
assert_eq!(executor.pending.len(), 1);
assert_eq!(
executor
.pending
.values()
.next()
.unwrap()
.parent_conversation_id,
second_conversation
);
});
});
}
#[test]
fn removing_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.remove_conversation(
state.child_conversation_id,
state.terminal_view_id,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn deleting_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.delete_conversation(
state.child_conversation_id,
Some(state.terminal_view_id),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn run_agents_dispatch_publishes_only_run_agents_child_link() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.direct_links.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.run_agents_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
state.action_id.clone(),
"child".to_string(),
state.parent_conversation_id,
state.child_conversation_id,
)]
);
});
});
}
#[test]
fn reattach_reuses_persisted_child_without_launching_another_agent() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured_prompts = capture_start_agent_prompts(&mut app, &executor);
let captured_links = capture_run_agents_child_links(&mut app, &executor);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.reattach(
action_id.clone(),
"child".to_string(),
parent_conversation_id,
child_conversation_id,
StartAgentWaitPolicy::Completion,
ctx,
)
});
assert_eq!(dispatch.wait_policy, StartAgentWaitPolicy::Completion);
assert!(matches!(
dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
captured_prompts.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
captured_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
action_id,
"child".to_string(),
parent_conversation_id,
child_conversation_id,
)]
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Success,
ctx,
);
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Completed { agent_id, .. })
if agent_id == child_conversation_id.to_string()
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
App::test((), |mut app| async move {
@@ -596,7 +1259,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() {
.pending
.get(&FIRST_REQUEST_ID)
.expect("direct child should remain pending until completion");
assert!(pending.wait_for_completion);
assert_eq!(pending.wait_policy, StartAgentWaitPolicy::Completion);
});
history_model.update(&mut app, |history_model, ctx| {
@@ -62,6 +62,9 @@ fn initialize_upload_artifact_test(
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
+374 -3
View File
@@ -3,7 +3,10 @@ use std::sync::Arc;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
@@ -13,6 +16,44 @@ fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
})
}
fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResult {
AIAgentActionResult {
id: AIAgentActionId::from(id.to_owned()),
task_id: TaskId::new("task".to_owned()),
result,
}
}
fn action(id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_string()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new("task".to_string()),
requires_result: true,
tool_name: Some("init_project".to_string()),
}
}
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(7),
},
calls: call_ids
.iter()
.map(|call_id| galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: (*call_id).to_string(),
name: "init_project".to_string(),
arguments: serde_json::json!({}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
})
.collect(),
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
@@ -35,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
count
}
#[test]
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
assert_eq!(correlations.len(), 2);
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
assert_eq!(correlations[0].1.call_id, "first");
let error = provider_action_correlations(
&[action("second"), action("first")],
conversation_id,
&batch,
)
.unwrap_err();
assert_eq!(
error,
ProviderActionQueueError::ActionSetMismatch {
expected: vec!["first".to_string(), "second".to_string()],
received: vec!["second".to_string(), "first".to_string()],
}
);
}
#[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase =
@@ -71,6 +140,47 @@ fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
}
#[test]
fn automatic_retries_only_target_actions_deferred_as_not_ready() {
let conversation_id = AIConversationId::new();
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut tracker = NotReadyActionTracker::default();
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::Automatic,
);
assert!(tracker.should_retry(conversation_id, &action_id));
assert!(!ActionExecutionInitiator::Automatic.is_user_initiated());
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::User,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NeedsConfirmation,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::WaitingOnSharer,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
assert!(ActionExecutionInitiator::User.is_user_initiated());
}
#[test]
fn finished_results_stay_in_original_action_order() {
let action_order = HashMap::from([
@@ -84,8 +194,7 @@ fn finished_results_stay_in_original_action_order() {
make_action_result("second"),
];
finished_results
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
sort_action_results_by_order(&mut finished_results, &action_order);
assert_eq!(
finished_results[0].id,
@@ -100,3 +209,265 @@ fn finished_results_stay_in_original_action_order() {
AIAgentActionId::from("third".to_owned())
);
}
#[test]
fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
let success = domain_tool_result(
&action_result("success", AIAgentActionResultType::InitProject),
false,
);
let failure = domain_tool_result(
&action_result(
"failure",
AIAgentActionResultType::Grep(GrepResult::Error("boom".to_string())),
),
false,
);
let cancelled_result = action_result(
"cancelled",
AIAgentActionResultType::Grep(GrepResult::Cancelled),
);
let cancelled = domain_tool_result(&cancelled_result, false);
let denied = domain_tool_result(&cancelled_result, true);
assert_eq!(success.status, ToolResultStatus::Success);
assert_eq!(failure.status, ToolResultStatus::Error);
assert_eq!(cancelled.status, ToolResultStatus::Cancelled);
assert_eq!(denied.status, ToolResultStatus::Denied);
assert_eq!(success.call_id, "success");
assert_eq!(failure.call_id, "failure");
assert_eq!(cancelled.call_id, "cancelled");
assert_eq!(denied.call_id, "cancelled");
assert!(!cancelled.content.contains("Permission denied"));
assert!(denied.content.contains("Permission denied by the user"));
}
#[test]
fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
let result = action_result(
"read-call",
AIAgentActionResultType::ReadFiles(ReadFilesResult::Success {
files: vec![FileContext::new(
"/workspace/src/lib.rs".to_string(),
AnyFileContent::StringContent("pub fn answer() -> u8 { 42 }".to_string()),
None,
None,
)],
}),
);
let result = domain_tool_result(&result, false);
assert_eq!(result.status, ToolResultStatus::Success);
assert_eq!(result.call_id, "read-call");
assert!(result.content.contains("/workspace/src/lib.rs"));
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
}
#[test]
fn action_permission_kinds_match_the_safety_boundary() {
assert_eq!(
permission_kind_for_action(&AIAgentActionType::Grep {
queries: vec!["needle".to_string()],
path: ".".to_string(),
}),
PermissionKind::Read
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::InitProject),
PermissionKind::Write
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::RequestCommandOutput {
command: "cargo test".to_string(),
is_read_only: Some(true),
is_risky: Some(false),
wait_until_completion: true,
uses_pager: Some(false),
rationale: None,
citations: Vec::new(),
}),
PermissionKind::Execute
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::CallMCPTool {
server_id: None,
name: "tool".to_string(),
input: serde_json::json!({}),
}),
PermissionKind::ExternalTool
);
}
#[test]
fn denied_permission_event_resolves_the_pending_call() {
let action = action("call-1");
let ToolEvent::PermissionResolved {
request_id,
call_id,
decision,
} = permission_denied_tool_event(&action)
else {
panic!("expected a permission resolution event");
};
assert_eq!(request_id, "permission:call-1");
assert_eq!(call_id, "call-1");
assert_eq!(
decision,
PermissionDecision::Denied {
reason: Some("Permission denied by the user.".to_string()),
}
);
}
#[test]
fn provider_owned_denial_suppresses_duplicate_completion() {
assert!(!should_emit_tool_completion(true, true));
assert!(should_emit_tool_completion(true, false));
assert!(should_emit_tool_completion(false, true));
}
#[test]
fn only_rejecting_a_blocked_action_is_a_permission_denial() {
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Blocked),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Queued),
));
assert!(!is_permission_denial(
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
Some(&AIActionStatus::Blocked),
));
}
#[test]
fn duplicate_action_ids_resolve_only_within_the_requested_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let first_result = make_action_result("duplicate");
let mut second_result = action_result("duplicate", AIAgentActionResultType::InitProject);
second_result.task_id = TaskId::new("second-task".to_string());
let second_result = Arc::new(second_result);
let finished_results = HashMap::from([(first_conversation, vec![first_result.clone()])]);
let provider_results = HashMap::new();
let archive = HashMap::from([
(
(first_conversation, duplicate_id.clone()),
first_result.clone(),
),
(
(second_conversation, duplicate_id.clone()),
second_result.clone(),
),
]);
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
first_conversation,
&duplicate_id,
)
.unwrap(),
&first_result,
));
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
second_conversation,
&duplicate_id,
)
.unwrap(),
&second_result,
));
assert!(action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
AIConversationId::new(),
&duplicate_id,
)
.is_none());
}
#[test]
fn cancellation_permission_inference_uses_the_matching_conversation_status() {
let blocked_conversation = AIConversationId::new();
let queued_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let pending_actions = HashMap::from([
(blocked_conversation, VecDeque::from([action("duplicate")])),
(
queued_conversation,
VecDeque::from([action("first"), action("duplicate")]),
),
]);
let running_actions = HashMap::new();
let blocked_status = pending_action_status(
&pending_actions,
&running_actions,
blocked_conversation,
&duplicate_id,
false,
);
let queued_status = pending_action_status(
&pending_actions,
&running_actions,
queued_conversation,
&duplicate_id,
false,
);
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
blocked_status.as_ref(),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
queued_status.as_ref(),
));
}
#[test]
fn action_lifecycle_events_disambiguate_duplicate_ids_by_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let events = [
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::ExecutingAction {
action_id: duplicate_id.clone(),
conversation_id: second_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::FinishedAction {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
cancellation_reason: None,
execution_ref: None,
},
];
assert_eq!(events[0].conversation_id(), Some(first_conversation));
assert_eq!(events[1].conversation_id(), Some(second_conversation));
assert_eq!(events[2].conversation_id(), Some(first_conversation));
assert!(events
.iter()
.all(|event| event.action_id() == &duplicate_id));
}
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
) -> Option<Box<dyn Element>> {
if !item.available_in().is_available_for_cli()
|| !item.available_to_session_viewer(shared_status, false)
|| !item.is_available(app)
{
return None;
}
@@ -2016,6 +2017,7 @@ impl AgentInputFooter {
});
if !item.available_in().is_available_for_agent_view()
|| !item.available_to_session_viewer(shared_status, is_cloud_mode)
|| !item.is_available(app)
{
return None;
}
@@ -178,6 +178,8 @@ impl AgentToolbarItemKind {
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
// Retain the enum variant so existing toolbar settings still deserialize.
Self::ShareSession => false,
_ => true,
}
}
@@ -215,11 +217,6 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage,
Self::ModelSelector,
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -247,11 +244,6 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -322,3 +314,7 @@ impl From<ContextChipKind> for AgentToolbarItemKind {
Self::ContextChip(kind)
}
}
#[cfg(test)]
#[path = "toolbar_item_tests.rs"]
mod tests;
@@ -0,0 +1,15 @@
use super::AgentToolbarItemKind;
#[test]
fn legacy_share_session_setting_remains_deserializable() {
let item: AgentToolbarItemKind =
serde_json::from_str("\"ShareSession\"").expect("legacy setting should deserialize");
assert_eq!(item, AgentToolbarItemKind::ShareSession);
}
#[test]
fn share_session_is_not_offered_by_defaults_or_configurator() {
assert!(!AgentToolbarItemKind::default_right().contains(&AgentToolbarItemKind::ShareSession));
assert!(!AgentToolbarItemKind::all_available().contains(&AgentToolbarItemKind::ShareSession));
}
+153 -39
View File
@@ -30,6 +30,7 @@ use base64::Engine as _;
use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent};
use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
}
}
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
if is_streaming
&& has_output
&& !self.user_toggled_while_streaming
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
{
self.expand();
}
self.sync_finished_state(is_finished);
if is_finished {
if let CollapsibleExpansionState::Expanded {
scroll_pinned_to_bottom,
..
} = &mut self.expansion_state
{
*scroll_pinned_to_bottom = false;
}
}
}
/// Applies orchestration message display behavior after streaming finishes.
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
let should_auto_collapse = self.should_auto_collapse_on_finish();
@@ -2174,6 +2196,19 @@ impl AIBlock {
}
match action {
AIAgentAction {
id: action_id,
action: AIAgentActionType::RequestFileEdits { title, file_edits },
..
} => {
self.ensure_requested_edit_view(
action_id,
title,
file_edits.clone(),
output.server_output_id.clone(),
ctx,
);
}
AIAgentAction {
id: action_id,
action:
@@ -2323,6 +2358,32 @@ impl AIBlock {
// Register element state for reasoning messages and track summarization timing.
for message in &output.messages {
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
let is_finished = matches!(
activity.status,
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
);
let has_output = activity
.output
.as_deref()
.is_some_and(|output| !output.is_empty());
let state = self
.collapsible_block_states
.entry(message.id.clone())
.or_insert_with(|| {
if is_streaming && has_output {
CollapsibleElementState::default()
} else {
CollapsibleElementState::collapsed()
}
});
state.sync_runtime_activity(is_streaming, is_finished, has_output);
}
if let AIAgentOutputMessageType::Reasoning {
finished_duration, ..
} = &message.message
@@ -2608,6 +2669,7 @@ impl AIBlock {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)
@@ -2712,7 +2774,7 @@ impl AIBlock {
},
..
} => {
self.handle_requested_edit_complete(
self.ensure_requested_edit_view(
id,
title,
file_edits.clone(),
@@ -3232,7 +3294,7 @@ impl AIBlock {
});
}
fn handle_requested_edit_complete(
fn ensure_requested_edit_view(
&mut self,
action_id: &AIAgentActionId,
title: &Option<String>,
@@ -3240,6 +3302,10 @@ impl AIBlock {
server_output_id: Option<ServerOutputId>,
ctx: &mut ViewContext<Self>,
) {
if self.requested_edits.contains_key(action_id) {
return;
}
let identifiers = AIIdentifiers {
client_conversation_id: Some(self.client_ids.conversation_id),
client_exchange_id: Some(self.client_ids.client_exchange_id),
@@ -3295,14 +3361,6 @@ impl AIBlock {
ctx,
)
});
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, _| {
executor.register_requested_edits(action_id, &view);
});
// If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload.
if self.action_model.as_ref(ctx).is_view_only() {
let active_session = self.active_session.as_ref(ctx);
@@ -3459,7 +3517,17 @@ impl AIBlock {
});
self.requested_edits
.insert(action_id.clone(), RequestedEdit::new(view));
.insert(action_id.clone(), RequestedEdit::new(view.clone()));
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, ctx| {
executor.register_requested_edits(action_id, &view, ctx);
});
self.action_model.update(ctx, |action_model, ctx| {
action_model.retry_not_ready_action(action_id, self.client_ids.conversation_id, ctx);
});
if self.model.request_type(ctx).is_passive() {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
@@ -3510,7 +3578,10 @@ impl AIBlock {
}
// Set the state based on the action status from the action model
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id)
@@ -3605,6 +3676,7 @@ impl AIBlock {
RequestedCommandViewEvent::Accepted => {
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
action_id,
view.as_ref(ctx).command_text().to_string(),
ctx,
@@ -3623,7 +3695,10 @@ impl AIBlock {
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
// We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block).
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = {
let terminal_model = self.terminal_model.lock();
terminal_model
@@ -3822,7 +3897,7 @@ impl AIBlock {
if self
.action_model
.as_ref(ctx)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_blocked())
{
ctx.focus(&view);
@@ -4206,7 +4281,10 @@ impl AIBlock {
// but it's not incorrect to populate if it is, and we rely on this for
// for restored conversations because action model events don't re-fire
// after the view is created.
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
if let Some(view) = self.search_codebase_view.get(action_id) {
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
@@ -4640,7 +4718,11 @@ impl AIBlock {
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
self.requested_action_ids
.iter()
.filter_map(|id| self.action_model.as_ref(app).get_action_status(id))
.filter_map(|id| {
self.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, id)
})
.any(|status| status.is_blocked())
}
@@ -4666,7 +4748,12 @@ impl AIBlock {
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
let action_id = event.action_id();
if me.is_finished() || !me.requested_action_ids.contains(action_id) {
if event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id)
|| me.is_finished()
|| !me.requested_action_ids.contains(action_id)
{
// Technically, this subscription should be unregistered after `is_finished` is
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
// call, so early return here if this is errantly being called.
@@ -4674,7 +4761,7 @@ impl AIBlock {
}
match event {
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction { .. } => {
match &me.autonomy_setting_speedbump {
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
action_id: speedbump_action_id,
@@ -4744,7 +4831,7 @@ impl AIBlock {
_ => {}
}
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
@@ -4760,7 +4847,7 @@ impl AIBlock {
{
let should_collapse = action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.is_none_or(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { exit_code, .. },
@@ -4775,7 +4862,9 @@ impl AIBlock {
}
if let Some(view) = me.search_codebase_view.get(action_id) {
let new_status = action_model.as_ref(ctx).get_action_status(action_id);
let new_status = action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id);
view.update(ctx, |view, ctx| {
view.update_status(new_status);
ctx.notify();
@@ -4784,7 +4873,9 @@ impl AIBlock {
// Create subagent panel state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) =
action_model.as_ref(ctx).get_action_status(action_id)
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id)
{
if let AIAgentActionResultType::StartAgent(
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
@@ -4806,7 +4897,11 @@ impl AIBlock {
let action_statuses = me
.requested_action_ids
.iter()
.filter_map(|id| action_model.as_ref(ctx).get_action_status(id))
.filter_map(|id| {
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, id)
})
.collect_vec();
// Detecting links on SearchCodebase tool call outputs
@@ -4839,7 +4934,9 @@ impl AIBlock {
view.update_render_read_file_args(
&me.find_state,
files.clone(),
action_model.as_ref(ctx).get_action_status(action_id),
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id),
);
ctx.notify();
})
@@ -4849,7 +4946,9 @@ impl AIBlock {
// Open the AI document pane when documents are created or edited
if let Some(action_result) =
action_model.as_ref(ctx).get_action_result(action_id)
action_model
.as_ref(ctx)
.get_action_result(me.client_ids.conversation_id, action_id)
{
match &action_result.result {
AIAgentActionResultType::CreateDocuments(
@@ -4901,7 +5000,7 @@ impl AIBlock {
}
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction(action_id) => {
BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
// Update search codebase view status when action is queued
if let Some(view) = me.search_codebase_view.get(action_id) {
view.update(ctx, |view, ctx| {
@@ -4929,7 +5028,8 @@ impl AIBlock {
}
}
BlocklistAIActionEvent::InitProject(_)
BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_) => {}
}
});
@@ -5608,7 +5708,9 @@ impl AIBlock {
/// This hides their keybindings in the UI and makes them less interactive.
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
self.action_model.update(ctx, |action_model, ctx| {
for action in action_model.get_pending_actions() {
for action in
action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
{
if let Some(edit) = self.requested_edits.get(&action.id) {
edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
@@ -5661,7 +5763,12 @@ impl AIBlock {
.view
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(&action_id, command_text, ctx);
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
&action_id,
command_text,
ctx,
);
});
ctx.notify();
}
@@ -5689,12 +5796,11 @@ impl AIBlock {
/// Finds the undismissed passive code diff across all pending actions.
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
let all_pending_actions = self.action_model.as_ref(app).get_pending_actions();
// Find any RequestFileEdits action that has a corresponding passive code diff view.
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
all_pending_actions
.iter()
self.action_model
.as_ref(app)
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
.find_map(|action| match &action.action {
AIAgentActionType::RequestFileEdits {
file_edits: _,
@@ -5734,7 +5840,10 @@ impl AIBlock {
.is_none_or(|output| {
output.get().actions().last().is_none_or(|action| {
let is_streaming = self.model.status(app).is_streaming();
let status = self.action_model.as_ref(app).get_action_status(&action.id);
let status = self
.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, &action.id);
is_streaming || status.is_some_and(|status| status.is_running())
})
})
@@ -5761,7 +5870,7 @@ impl AIBlock {
.any(|(action_id, requested_command)| {
self.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_running())
&& requested_command.view.as_ref(app).is_header_expanded()
})
@@ -5861,7 +5970,10 @@ impl AIBlock {
return String::new();
};
let output = output.get();
output.format_for_copy(Some(self.action_model.as_ref(app)))
output.format_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
)
}
/// Gets AI output text for copying from the preceding user query until the next user query
@@ -5916,8 +6028,10 @@ impl AIBlock {
// Collect all AI outputs from start_idx to end_idx (exclusive)
let mut combined_result = Vec::new();
for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
let formatted_output =
exchange.format_output_for_copy(Some(self.action_model.as_ref(app)));
let formatted_output = exchange.format_output_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
);
if !formatted_output.is_empty() {
combined_result.push(formatted_output);
}
@@ -7089,7 +7203,7 @@ impl TypedActionView for AIBlock {
let Some(result) = self
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(self.client_ids.conversation_id, action_id)
.map(Arc::clone)
else {
continue;
+4 -2
View File
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
let is_cancelled = self
.action_model
.as_ref(app)
.get_action_status(&action.id)
.get_action_status(self.conversation_id, &action.id)
.is_some_and(|status| status.is_cancelled());
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app)
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|| (is_latest_exchange
&& matches!(
message,
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::WebSearch(_)
))
}
+376 -64
View File
@@ -15,6 +15,7 @@ use crate::ai::agent::{
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
@@ -40,9 +41,14 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
initial_requested_command_conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
/// Prevents a monitor turn that ended with prose and no tool call from recursively
/// generating nudges. A real snapshot/action result resets this so the next turn can be
/// nudged again if it stalls in the same way.
monitor_nudge_sent: bool,
completion: Option<PendingCommandCompletion>,
}
@@ -52,6 +58,7 @@ struct PendingCommandCompletion {
initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String,
completed_command: RunningCommand,
exit_code: i32,
final_turn_started: bool,
}
@@ -67,7 +74,7 @@ impl UserTakeOverReason {
pub fn transfer_reason(&self) -> Option<&str> {
match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None,
Self::Manual | Self::Stop => None,
}
}
}
@@ -161,12 +168,25 @@ impl CLISubagentController {
return;
};
me.advance_completed_subagents(*conversation_id, ctx);
me.ensure_monitor_continues(*conversation_id, ctx);
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned();
@@ -176,9 +196,21 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
@@ -190,12 +222,13 @@ impl CLISubagentController {
}
BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id,
conversation_id,
..
} => {
let action_result = me
.action_model
.as_ref(ctx)
.get_action_result(finished_action_id);
.get_action_result(*conversation_id, finished_action_id);
let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| {
matches!(
@@ -215,38 +248,47 @@ impl CLISubagentController {
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
if matches_active_requested_command(
*conversation_id,
finished_action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
active_block.update_is_agent_blocked(false);
let active_command_action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
let active_command_action_id =
active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id {
me.active_subagents_by_block
let state = me
.active_subagents_by_block
.entry(snapshot_block_id.clone())
.or_default()
.last_snapshot_at = Some(Instant::now());
.or_default();
state.last_snapshot_at = Some(Instant::now());
state.monitor_nudge_sent = false;
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref()
!= Some(finished_action_id)
|| !matches_requested_command_identity(
*conversation_id,
finished_action_id,
state.initial_requested_command_conversation_id,
state.initial_requested_command_action_id.as_ref(),
)
});
}
drop(terminal_model);
if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
me.advance_completed_subagent(&block_id, ctx);
}
}
_ => (),
@@ -265,6 +307,8 @@ impl CLISubagentController {
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let should_skip_completion_assessment =
!should_request_completion_assessment(block.long_running_control_state());
let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() {
@@ -294,6 +338,7 @@ impl CLISubagentController {
requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false,
},
exit_code,
final_turn_started: false,
})
}
@@ -310,17 +355,70 @@ impl CLISubagentController {
};
drop(terminal_model);
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
return;
};
if subagent_state.last_snapshot_at.is_some() {
let provider_accepted_completion = completion.as_ref().is_some_and(|completion| {
let provider_completion = PendingProviderCommandCompletion::new(
completion.completed_command.block_id.clone(),
completion.initial_requested_command_action_id.clone(),
completion.completed_command.command.clone(),
completion.completed_command.grid_contents.clone(),
completion.exit_code,
);
me.controller.update(ctx, |controller, ctx| {
controller.offer_provider_command_completion(
completion.conversation_id,
provider_completion,
ctx,
)
})
});
let has_last_snapshot = me
.active_subagents_by_block
.get(&block_id)
.is_some_and(|state| state.last_snapshot_at.is_some());
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
subagent_state.completion = completion;
if subagent_state.completion.is_none() {
if provider_accepted_completion {
// The provider controller owns deactivation after it applies the queued
// completion at a safe run boundary.
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
return;
}
// A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also
// clean up the in-memory monitor state so the stopped subagent cannot linger in
// the UI or intercept later refreshes.
if should_skip_completion_assessment {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
let has_completion = {
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
else {
return;
};
subagent_state.completion = completion;
subagent_state.completion.is_some()
};
if !has_completion {
log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata"
);
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
me.advance_completed_subagent(&block_id, ctx);
@@ -359,10 +457,10 @@ impl CLISubagentController {
}
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self
let Some(completion) = self
.active_subagents_by_block
.get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone())))
.and_then(|state| state.completion.as_ref().cloned())
else {
return;
};
@@ -380,14 +478,18 @@ impl CLISubagentController {
}
if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx);
self.finish_subagent(
block_id,
Some(completion.conversation_id),
completion.initial_requested_command_action_id,
ctx,
);
return;
}
let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment(
completion.conversation_id,
task_id,
completion.prompt,
completion.completed_command,
ctx,
@@ -404,38 +506,125 @@ impl CLISubagentController {
}
}
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let Some(completion) = state.completion else {
/// A monitor turn that returns only prose has no action result to trigger the normal
/// action-follow-up path. Nudge that monitor once with the live command context so a model
/// that acknowledged the first snapshot without polling gets another chance to inspect it.
fn ensure_monitor_continues(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(block_id) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| {
conversation.all_tasks().find_map(|task| {
let block_id = task.cli_subagent_block_id()?;
let state = self.active_subagents_by_block.get(&block_id)?;
if state.task_id.as_ref() != Some(task.id()) || state.completion.is_some() {
return None;
}
let last_exchange_has_action = task.last_exchange().is_some_and(|exchange| {
exchange
.output_status
.output()
.is_some_and(|output| output.get().actions().next().is_some())
});
should_nudge_monitor_turn(last_exchange_has_action, state.monitor_nudge_sent)
.then_some(block_id)
})
})
else {
return;
};
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.deactivate_cli_subagent_task_for_conversation(
block_id,
completion.conversation_id,
)
});
if let Err(error) = deactivate_result {
log::error!(
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
if self
.controller
.as_ref(ctx)
.has_active_provider_run(conversation_id)
|| self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return;
}
let command_is_still_agent_controlled = {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| {
block.is_active_and_long_running()
&& block.is_agent_in_control()
&& block.ai_conversation_id() == Some(conversation_id)
})
};
if !command_is_still_agent_controlled {
return;
}
if let Some(state) = self.active_subagents_by_block.get_mut(&block_id) {
state.monitor_nudge_sent = true;
}
self.controller.update(ctx, |controller, ctx| {
controller.send_cli_monitor_nudge(conversation_id, ctx);
});
}
fn finish_subagent(
&mut self,
block_id: &BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let conversation_id = conversation_id.or_else(|| {
state
.completion
.as_ref()
.map(|completion| completion.conversation_id)
});
let initial_requested_command_action_id = initial_requested_command_action_id
.or_else(|| {
state
.completion
.as_ref()
.and_then(|completion| completion.initial_requested_command_action_id.clone())
})
.or(state.initial_requested_command_action_id);
if let Some(conversation_id) = conversation_id {
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
});
if let Err(error) = deactivate_result {
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id),
initial_requested_command_action_id: completion.initial_requested_command_action_id,
conversation_id,
initial_requested_command_action_id,
});
if let Some(agent_view_controller) = &self.agent_view_controller {
if let (Some(agent_view_controller), Some(conversation_id)) =
(&self.agent_view_controller, conversation_id)
{
agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id);
== Some(conversation_id);
if is_this_inline_conversation {
controller.exit_agent_view(ctx);
}
@@ -469,11 +658,18 @@ impl CLISubagentController {
///
/// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
self.active_subagents_by_block
pub fn track_requested_command(
&mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
let state = self
.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_action_id = Some(action_id.clone());
.or_default();
state.initial_requested_command_conversation_id = Some(conversation_id);
state.initial_requested_command_action_id = Some(action_id.clone());
}
/// Force the currently in-flight poll for the given long-running command block to
@@ -609,13 +805,7 @@ impl CLISubagentController {
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
controller.resume_conversation(conversation_id, resume_context, ctx);
});
}
}
@@ -726,6 +916,10 @@ impl CLISubagentController {
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_conversation_id = Some(conversation_id);
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
@@ -874,6 +1068,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. },
)
| AIAgentActionResultType::WriteToLongRunningShellCommand(
@@ -919,3 +1114,120 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
| AIAgentActionResultType::WaitForEvents(_) => None,
}
}
fn should_request_completion_assessment(
control_state: Option<&LongRunningCommandControlState>,
) -> bool {
!control_state
.and_then(LongRunningCommandControlState::user_take_over_reason)
.is_some_and(UserTakeOverReason::is_stop)
}
fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent: bool) -> bool {
!last_exchange_has_action && !monitor_nudge_sent
}
fn matches_active_requested_command(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
active_conversation_id: Option<AIConversationId>,
active_requested_command_id: Option<&AIAgentActionId>,
) -> bool {
active_conversation_id == Some(event_conversation_id)
&& active_requested_command_id == Some(event_action_id)
}
fn matches_requested_command_identity(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
requested_command_conversation_id: Option<AIConversationId>,
requested_command_action_id: Option<&AIAgentActionId>,
) -> bool {
requested_command_conversation_id == Some(event_conversation_id)
&& requested_command_action_id == Some(event_action_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
}
+10
View File
@@ -1,5 +1,6 @@
use std::time::Duration;
use galaxy_agent_core::RuntimeActivity;
use galaxy_terminal::model::escape_sequences;
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
});
assert!(!should_retain_task_output_message(&poll, false));
assert!(should_retain_task_output_message(&poll, true));
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
id: "acp-tool".to_owned(),
title: "Inspect repository".to_owned(),
status: None,
output: None,
});
assert!(!should_retain_task_output_message(&runtime_activity, false));
assert!(should_retain_task_output_message(&runtime_activity, true));
}
+5 -1
View File
@@ -149,7 +149,11 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
let output = output.get();
output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.message {
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) {
if let Some(status) = self.conversation_id(app).and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, &action.id)
}) {
return status.is_blocked().then_some(action.clone());
}
}
+21 -4
View File
@@ -328,10 +328,27 @@ impl BlocklistAIStatusBar {
},
);
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..)
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (),
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction {
conversation_id, ..
}
| BlocklistAIActionEvent::FinishedAction {
conversation_id, ..
} if me
.active_exchange_model
.as_ref()
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)) =>
{
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. }
| BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_)
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => {}
});
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => {
+2
View File
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
contents.add_child(output::render(
output::Props {
conversation_id: self.client_ids.conversation_id,
model: self.model.as_ref(),
state_handles: &self.state_handles,
action_buttons: &self.action_buttons,
@@ -1375,6 +1376,7 @@ impl AIAgentInput {
app,
)),
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
@@ -3687,6 +3687,7 @@ pub(super) fn query_prefix_highlight_len(
match input {
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let orchestrator_agent_id = props
.model
.conversation(app)
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else {
+189 -28
View File
@@ -16,12 +16,13 @@ use ai::agent::action::{
};
use ai::agent::file_locations::group_file_contexts_for_display;
use ai::skills::{ParsedSkill, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use galaxy_core::channel::ChannelState;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable,
MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap,
@@ -55,6 +56,7 @@ use super::{
};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
@@ -84,18 +86,22 @@ use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestion
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
use crate::ai::blocklist::inline_action::inline_action_header::{
HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::{
render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction,
};
use crate::ai::blocklist::inline_action::requested_command::RequestedCommand;
use crate::ai::blocklist::inline_action::requested_command::{
format_command_text, RequestedCommand, REQUESTED_COMMAND_BODY_VERTICAL_PADDING,
VIEWING_COMMAND_DETAIL_MESSAGE,
};
use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView;
use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView;
use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView;
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons;
@@ -131,9 +137,14 @@ use crate::{AIAgentTodoList, FeatureFlag};
const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?";
fn should_render_requested_edit(action_status: Option<&AIActionStatus>) -> bool {
!action_status.is_some_and(AIActionStatus::is_preprocessing)
}
/// Data required to render the AI block output component.
#[derive(Copy, Clone)]
pub(crate) struct Props<'a> {
pub(crate) conversation_id: AIConversationId,
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
pub(super) state_handles: &'a AIBlockStateHandles,
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
@@ -400,6 +411,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
} if !are_all_text_sections_empty(sections) => {
text_section_index += sections.len();
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
if !matches!(
activity.status,
Some(RuntimeActivityStatus::Completed)
| Some(RuntimeActivityStatus::Failed)
) {
should_render_footer = false;
should_render_suggestions = false;
}
if let Some(rendered_activity) =
render_runtime_activity(output_message, activity, props, app)
{
output_items.add_child(rendered_activity);
}
}
AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::RequestCommandOutput { .. },
id,
@@ -412,7 +438,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -452,7 +478,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
// checks if the read file action result is completed and successful.
@@ -541,13 +567,12 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
id,
..
}) => {
let action_status =
props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_preprocessing = action_status
.clone()
.is_some_and(|status| status.is_preprocessing());
if !is_preprocessing && !status.is_streaming() {
if should_render_requested_edit(action_status.as_ref()) {
if let Some(requested_edit) = props.requested_edits.get(id) {
// Don't render the requested edit if the diffs are empty for passive code diffs.
if request_type.is_passive_code_diff()
@@ -635,7 +660,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -1262,6 +1287,106 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
output_items.finish()
}
fn render_runtime_activity(
output_message: &AIAgentOutputMessage,
activity: &RuntimeActivity,
props: Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let state = props.collapsible_block_states.get(&output_message.id)?;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let output = activity
.output
.as_deref()
.filter(|output| !output.is_empty());
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let icon = match activity.status.as_ref() {
Some(RuntimeActivityStatus::Pending) => icons::pending_icon(appearance),
Some(RuntimeActivityStatus::InProgress) => icons::yellow_running_icon(appearance),
Some(RuntimeActivityStatus::Completed) => inline_action_icons::green_check_icon(appearance),
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance),
Some(RuntimeActivityStatus::Other(_)) | None => icons::gray_circle_icon(appearance),
};
let title = if is_expanded {
VIEWING_COMMAND_DETAIL_MESSAGE.to_owned()
} else {
format_command_text(&activity.title)
};
let mut header = HeaderConfig::new(title, app)
.with_selectable_text()
.with_icon(icon)
.with_corner_radius_override(if is_expanded && output.is_some() {
CornerRadius::with_top(Radius::Pixels(8.))
} else {
CornerRadius::with_all(Radius::Pixels(8.))
});
if !is_expanded {
header = header.with_font_family(appearance.monospace_font_family());
}
if output.is_some() {
let message_id = output_message.id.clone();
let command = activity.title.clone();
let expansion =
ExpandedConfig::new(is_expanded, state.expansion_toggle_mouse_state.clone())
.with_toggle_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id.clone(),
));
})
.with_right_click_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
command: command.clone(),
});
});
header = header.with_interaction_mode(InteractionMode::ManuallyExpandable(expansion));
}
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Clipped::new(header.render(app)).finish());
if let Some(output) = output {
let body = render_requested_action_body_text(
output.into(),
appearance.monospace_font_family(),
app,
)
.finish();
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
if let Some(scrollable) = render_scrollable_collapsible_content(
&output_message.id,
state,
body,
is_streaming,
320.,
) {
content.add_child(
Container::new(scrollable)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(REQUESTED_COMMAND_BODY_VERTICAL_PADDING)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
}
Some(render_tool_pane_shell(
content.finish(),
false,
is_expanded,
false,
app,
))
}
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
if FeatureFlag::AgentView.is_enabled() {
return false;
@@ -1358,7 +1483,10 @@ fn render_search_codebase(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
@@ -1859,7 +1987,10 @@ fn render_read_files(
parsed_skill: Option<&ai::skills::ParsedSkill>,
action_index: usize,
) -> Box<dyn Element> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let formatted_files =
render_read_files_text(props.into(), file_names, app, appearance, action_index);
@@ -1976,7 +2107,10 @@ fn maybe_render_edit_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -1986,7 +2120,7 @@ fn maybe_render_edit_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2013,7 +2147,10 @@ fn maybe_render_create_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -2023,7 +2160,7 @@ fn maybe_render_create_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2326,7 +2463,7 @@ fn render_suggest_new_conversation(
let status = props
.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(props.conversation_id, action_id)
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
@@ -2434,7 +2571,10 @@ fn create_formatted_text_for_grep(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2538,7 +2678,10 @@ fn create_formatted_text_for_file_glob(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2639,7 +2782,10 @@ fn render_file_retrieval_tool(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
@@ -2756,7 +2902,10 @@ fn render_read_mcp_resource(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(name, app);
@@ -2833,11 +2982,14 @@ fn render_upload_artifact(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let result = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.and_then(|result| match &result.result {
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
_ => None,
@@ -2896,7 +3048,7 @@ fn render_use_computer(
let has_screenshot = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.is_some_and(|result| {
matches!(
&result.result,
@@ -2942,7 +3094,10 @@ fn render_request_computer_use(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(&request.task_summary, app);
@@ -3523,7 +3678,13 @@ pub fn action_icon<V: View>(
app: &AppContext,
) -> galaxyui::elements::Icon {
let appearance = Appearance::as_ref(app);
let status = action_model.as_ref(app).get_action_status(action_id);
let status = ai_block_model
.conversation_id(app)
.and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, action_id)
});
match status {
Some(status) => match status {
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
@@ -11,12 +11,23 @@ use watcher::HomeDirectoryWatcher;
use super::{
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
should_render_requested_edit,
};
use crate::ai::agent::UploadArtifactResult;
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::skills::SkillManager;
use crate::settings::AISettings;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
#[test]
fn requested_edits_render_as_soon_as_preprocessing_finishes() {
assert!(!should_render_requested_edit(Some(
&AIActionStatus::Preprocessing
)));
assert!(should_render_requested_edit(Some(&AIActionStatus::Blocked)));
assert!(should_render_requested_edit(None));
}
#[test]
fn format_upload_artifact_text_includes_request_details() {
let request = UploadArtifactRequest {
+48
View File
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
));
}
#[test]
fn completed_runtime_activity_stays_collapsed_until_opened() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn streaming_runtime_activity_expands_when_output_arrives() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: false,
scroll_pinned_to_bottom: true
}
));
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
}
#[test]
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn orchestration_show_and_collapse_collapses_after_finish() {
let mut state = default_collapsible_state_for_orchestration_message(
+25 -1
View File
@@ -188,13 +188,37 @@ impl BlocklistAIContextModel {
);
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
if matches!(
event,
LLMPreferencesEvent::UpdatedActiveAgentModeLLM
| LLMPreferencesEvent::UpdatedAvailableLLMs
) {
let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported =
llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id));
#[cfg(not(target_family = "wasm"))]
let desired_backend =
llm_prefs.agent_backend_for_active_model(Some(me.terminal_surface_id), ctx);
if !vision_supported {
me.clear_pending_images(ctx);
}
// ACP and provider histories have different owners. When the
// selected model crosses that boundary, make the next prompt a
// fresh conversation instead of silently sending it through
// the backend that owned the existing conversation.
#[cfg(not(target_family = "wasm"))]
{
let selected_backend = me
.selected_conversation(ctx)
.map(|conversation| conversation.agent_backend().clone());
if selected_backend.is_some_and(|backend| backend != desired_backend) {
me.set_pending_query_state_for_new_conversation(
AgentViewEntryOrigin::ConversationSelector,
ctx,
);
}
}
}
});
File diff suppressed because it is too large Load Diff
@@ -52,11 +52,15 @@ impl PendingResponseStreams {
.collect()
}
/// Attempts to inject a plain-text follow-up into the active ACP turn.
pub fn has_stream(&self, stream_id: &ResponseStreamId) -> bool {
self.streams.contains_key(stream_id)
}
/// Attempts to inject a plain-text follow-up into an active steerable runtime.
///
/// Returning `None` leaves the caller free to use the normal
/// cancel-and-queue path without dropping the user's message.
pub fn try_steer_acp_stream_for_conversation(
pub fn try_steer_runtime_for_conversation(
&self,
conversation_id: AIConversationId,
display_text: String,
@@ -71,7 +75,7 @@ impl PendingResponseStreams {
let model_id = stream.as_ref(app).llm_id().clone();
stream
.as_ref(app)
.try_steer_acp(display_text)
.try_steer_runtime(display_text)
.then(|| (stream_id.clone(), model_id))
}
@@ -87,6 +91,14 @@ impl PendingResponseStreams {
self.streams.insert(stream_id, stream);
}
pub fn register_additional_stream(
&mut self,
stream_id: ResponseStreamId,
stream: ModelHandle<ResponseStream>,
) {
self.streams.insert(stream_id, stream);
}
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id);
}
@@ -136,11 +148,13 @@ impl PendingResponseStreams {
false
} else {
for response_stream in streams_to_cancel.into_iter() {
log::info!(
crate::ai::tool_diagnostics::tool_debug!(
"Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
reason={reason}"
);
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
}
response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
File diff suppressed because it is too large Load Diff
@@ -1,86 +1,22 @@
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
use warp_multi_agent_api::response_event::stream_finished;
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
use super::{is_interactive_remote_command, stream_finished_llm_finished};
#[test]
fn pre_action_failures_retry() {
assert_eq!(
recovery_action(false, true, true, true, true),
RecoveryAction::RetryNow
);
// Resume eligibility is irrelevant pre-actions.
assert_eq!(
recovery_action(false, true, true, false, true),
RecoveryAction::RetryNow
);
}
#[test]
fn pre_action_failures_wait_for_connectivity_when_offline() {
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
fn response_finish_reason_reports_whether_the_llm_completed() {
assert!(stream_finished_llm_finished(&None));
assert!(stream_finished_llm_finished(&Some(
stream_finished::Reason::Done(stream_finished::Done {})
)));
assert!(stream_finished_llm_finished(&Some(
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
)));
assert!(!stream_finished_llm_finished(&Some(
stream_finished::Reason::Other(stream_finished::Other {})
)));
assert!(!stream_finished_llm_finished(&Some(
stream_finished::Reason::LlmUnavailable(stream_finished::LlmUnavailable {})
)));
}
#[test]
@@ -354,7 +354,7 @@ impl BlocklistAIController {
if self
.action_model
.as_ref(ctx)
.get_action_result(&result.id)
.get_action_result(conversation_id, &result.id)
.is_none()
{
self.action_model.update(ctx, |action_model, ctx| {
@@ -148,6 +148,19 @@ impl SlashCommandRequest {
is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == conversation_id),
};
if controller.should_block_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
ctx,
) {
controller.log_blocked_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
is_queued_prompt,
ctx,
);
return;
}
if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress(
active_conversation_id,
@@ -181,7 +194,6 @@ impl SlashCommandRequest {
entrypoint,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
is_queued_prompt,
ctx,
) {
File diff suppressed because it is too large Load Diff
+204 -52
View File
@@ -33,14 +33,16 @@ use crate::ai::agent::conversation::{
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus,
CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost,
Suggestions,
AIAgentAction, AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError,
RequestCost, Suggestions,
};
use crate::ai::artifacts::Artifact;
use crate::ai::document::ai_document_model::AIDocumentModel;
#[cfg(not(target_family = "wasm"))]
use crate::ai::llms::LLMPreferences;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::input_suggestions::HistoryOrder;
use crate::persistence::model::{
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
@@ -561,6 +563,44 @@ impl BlocklistAIHistoryModel {
conversation.write_updated_conversation_state(ctx);
}
pub(crate) fn persist_active_provider_run_json(
&mut self,
conversation_id: AIConversationId,
snapshot: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.set_active_provider_run_json(snapshot);
conversation.write_updated_conversation_state(ctx);
Ok(())
}
pub(crate) fn rebind_provider_projection(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.rebind_provider_projection(
task_id,
exchange_id,
response_stream_id,
terminal_surface_id,
ctx,
)?;
Ok(())
}
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
return;
@@ -1182,6 +1222,87 @@ impl BlocklistAIHistoryModel {
});
}
fn configured_agent_backend(
terminal_surface_id: EntityId,
is_viewing_shared_session: bool,
is_cli_agent_transcript: bool,
ctx: &AppContext,
) -> AgentBackend {
if is_viewing_shared_session
|| is_cli_agent_transcript
|| !cfg!(unix)
|| !FeatureFlag::AgentClientProtocol.is_enabled()
{
return AgentBackend::Provider;
}
let settings = AISettings::as_ref(ctx);
if !*settings.acp_enabled.value() {
return AgentBackend::Provider;
}
#[cfg(not(target_family = "wasm"))]
if let Some(llm_preferences) = ctx.try_get_singleton_model_as_ref::<LLMPreferences>() {
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
}
let providers = settings.enabled_acp_providers();
let [provider] = providers.as_slice() else {
return AgentBackend::Provider;
};
let agent_id = provider.agent_id.trim();
let agent_id = if agent_id.is_empty() {
"codex"
} else {
agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint =
acp_launch_fingerprint(agent_id, &provider.command, &provider.args);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
provider_id: provider.id.clone(),
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
&provider.config_options,
),
})
}
/// Reconciles a conversation without agent output with the currently enabled local runtime.
///
/// Agent views can create their initial conversation before the user changes runtime settings,
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
/// either case use ACP without mixing successful provider output into an ACP-owned history.
pub(crate) fn refresh_conversation_backend_without_output(
&mut self,
conversation_id: AIConversationId,
ctx: &AppContext,
) {
let Some(conversation) = self.conversation(&conversation_id) else {
return;
};
let Some(terminal_surface_id) = self.terminal_surface_id_for_conversation(&conversation_id)
else {
return;
};
let agent_backend = Self::configured_agent_backend(
terminal_surface_id,
conversation.is_viewing_shared_session(),
conversation.is_cli_agent_transcript(),
ctx,
);
if conversation.agent_backend() == &agent_backend {
return;
}
if let Some(conversation) = self.conversation_mut(&conversation_id) {
conversation.set_agent_backend_if_no_output(agent_backend);
}
}
/// Starts a new conversation in the given terminal surface's history, effectively marking the
/// existing conversation (if any) as completed.
///
@@ -1197,55 +1318,12 @@ impl BlocklistAIHistoryModel {
is_cli_agent_transcript: bool,
ctx: &mut ModelContext<Self>,
) -> AIConversationId {
let agent_backend = if !is_viewing_shared_session
&& !is_cli_agent_transcript
&& cfg!(unix)
&& FeatureFlag::AgentClientProtocol.is_enabled()
{
let settings = AISettings::as_ref(ctx);
if *settings.acp_enabled.value() {
let configured_agent_id = settings.acp_agent_id.value().trim();
let agent_id = if configured_agent_id.is_empty() {
"codex"
} else {
configured_agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint = acp_launch_fingerprint(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) = LLMPreferences::as_ref(ctx)
.selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(
&agent.config_options,
)
})
.unwrap_or_default(),
})
} else {
AgentBackend::Provider
}
} else {
AgentBackend::Provider
};
let agent_backend = Self::configured_agent_backend(
terminal_surface_id,
is_viewing_shared_session,
is_cli_agent_transcript,
ctx,
);
let mut new_conversation = AIConversation::new_with_agent_backend(
is_viewing_shared_session,
is_cli_agent_transcript,
@@ -1322,7 +1400,21 @@ impl BlocklistAIHistoryModel {
ctx: &mut ModelContext<Self>,
) {
if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) {
#[cfg(not(target_family = "wasm"))]
let remote_log_context =
remote_status_log_context(conversation, &status, error.as_ref());
conversation.update_status_with_error(status, error, terminal_surface_id, ctx);
#[cfg(not(target_family = "wasm"))]
if let Some(context) = remote_log_context {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level: RemoteLogLevel::Info,
message: "Agent conversation status changed".to_string(),
context,
},
);
}
}
}
@@ -1598,6 +1690,7 @@ impl BlocklistAIHistoryModel {
let conversation_data = AgentConversationData {
agent_backend: source_conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
reverted_action_ids,
@@ -1762,6 +1855,7 @@ impl BlocklistAIHistoryModel {
// be recomputed based on the retained exchanges in a follow-up.
let conversation_data = AgentConversationData {
agent_backend: conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids,
@@ -1852,6 +1946,21 @@ impl BlocklistAIHistoryModel {
Ok(())
}
pub fn apply_domain_tool_proposal(
&mut self,
response_stream_id: &ResponseStreamId,
conversation_id: AIConversationId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
self.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?
.apply_domain_tool_proposal(response_stream_id, terminal_surface_id, action, ctx)?;
Ok(())
}
pub fn update_conversation_cost_and_usage_for_request(
&mut self,
conversation_id: AIConversationId,
@@ -2755,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
// Placeholder authoritative.
agent_backend: placeholder.agent_backend().clone(),
// Active process-local provider runs cannot be merged from a cloud transcript.
active_provider_run_json: None,
// Cloud authoritative.
server_conversation_token: cloud_conversation
.server_conversation_token()
@@ -2817,6 +2929,46 @@ fn agent_id_key_from_persisted_data(conversation_data: &AgentConversationData) -
conversation_data.run_id.as_deref()
}
#[cfg(not(target_family = "wasm"))]
fn remote_status_log_context(
conversation: &AIConversation,
new_status: &ConversationStatus,
error: Option<&RenderableAIError>,
) -> Option<serde_json::Value> {
let prev_status = conversation.status();
if prev_status == new_status {
return None;
}
Some(serde_json::json!({
"event": "agent_conversation_status_changed",
"conversation_id": conversation.id().to_string(),
"parent_conversation_id": conversation.parent_conversation_id().map(|id| id.to_string()),
"agent_id": conversation.orchestration_agent_id(),
"agent_name": conversation.agent_name(),
"harness_type": conversation.orchestration_harness_type(),
"is_child": conversation.parent_conversation_id().is_some(),
"is_remote_child": conversation.is_remote_child(),
"previous_status": conversation_status_label(prev_status),
"new_status": conversation_status_label(new_status),
"new_status_is_terminal": new_status.is_done(),
"error": error.map(remote_logging::sanitize_error),
}))
}
#[cfg(not(target_family = "wasm"))]
fn conversation_status_label(status: &ConversationStatus) -> &'static str {
match status {
ConversationStatus::InProgress => "in_progress",
ConversationStatus::Success => "success",
ConversationStatus::Error => "error",
ConversationStatus::TransientError => "transient_error",
ConversationStatus::Cancelled => "cancelled",
ConversationStatus::Blocked { .. } => "blocked",
ConversationStatus::WaitingForEvents => "waiting_for_events",
}
}
/// Whether an `UpdatedConversationStatus` event represents a restoration
/// (the conversation was re-loaded for a terminal surface; the underlying
/// `ConversationStatus` did not change) or a real status set, in which case
+295 -2
View File
@@ -21,8 +21,10 @@ use crate::ai::agent::conversation::{
ServerAIConversationMetadata,
};
use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput,
RenderableAIError, Shared, TransientNetworkErrorKind, UserQueryMode,
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchange, AIAgentExchangeId,
AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
AIAgentOutputStatus, AIAgentText, AIAgentTextSection, AgentOutputText, FinishedAIAgentOutput,
MessageId, RenderableAIError, RunningCommand, Shared, TransientNetworkErrorKind, UserQueryMode,
};
use crate::ai::ambient_agents::{
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
@@ -78,6 +80,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
assert_eq!(
conversation.agent_backend(),
&AgentBackend::Acp(AcpConversationData {
provider_id: "legacy".to_string(),
agent_id: "codex".to_string(),
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
session_id: None,
@@ -88,6 +91,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
});
}
#[test]
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(false, ctx)
.expect("ACP setting should update");
});
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |model, ctx| {
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.read(&app, |model, _| {
assert_eq!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
&AgentBackend::Provider
);
});
history_model.update(&mut app, |model, _| {
let now = Local::now();
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(AIAgentExchange {
id: AIAgentExchangeId::new(),
input: Vec::new(),
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error {
output: None,
error: RenderableAIError::other("No AI provider configured", true),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("none"),
request_cost: None,
coding_model_id: LLMId::from("none"),
cli_agent_model_id: LLMId::from("none"),
computer_use_model_id: LLMId::from("none"),
response_initiator: None,
});
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(true, ctx)
.expect("ACP setting should update");
});
history_model.update(&mut app, |model, ctx| {
model.refresh_conversation_backend_without_output(conversation_id, ctx);
});
history_model.read(&app, |model, _| {
assert!(matches!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
AgentBackend::Acp(_)
));
});
});
}
/// Helper function to create a PersistedAIInput for testing
fn create_persisted_query(
query_text: &str,
@@ -144,6 +223,101 @@ fn repeated_command_steering_reuses_the_active_cli_subtask() {
});
}
#[test]
fn provider_tool_proposal_creates_exchange_for_tool_first_cli_turn() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let stream_id = ResponseStreamId::new_for_test();
let action_id = AIAgentActionId::from("monitor-tool-call".to_owned());
let (conversation_id, cli_task_id, action) =
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let root_task_id = model
.conversation(&conversation_id)
.expect("conversation should exist")
.get_root_task_id()
.clone();
model
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(root_task_id, Vec::new())]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_view_id,
ctx,
)
.expect("root response exchange should be recorded");
model.initialize_output_for_response_stream(
&stream_id,
conversation_id,
terminal_view_id,
warp_multi_agent_api::response_event::StreamInit {
request_id: "provider-request".to_owned(),
conversation_id: "provider-conversation".to_owned(),
run_id: "provider-run".to_owned(),
},
ctx,
);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
BlockId::new(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let action = AIAgentAction {
id: action_id.clone(),
task_id: cli_task_id.clone(),
action: AIAgentActionType::FileGlob {
patterns: vec!["*.rs".to_owned()],
path: None,
},
requires_result: true,
tool_name: Some("file_glob".to_owned()),
};
model
.apply_domain_tool_proposal(
&stream_id,
conversation_id,
terminal_view_id,
action.clone(),
ctx,
)
.expect("tool-first CLI proposal should attach to a lazy exchange");
(conversation_id, cli_task_id, action)
});
history_model.read(&app, |model, _| {
let conversation = model
.conversation(&conversation_id)
.expect("conversation should exist");
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI subtask should exist");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
conversation.exchange_id_for_action(&action.id),
cli_task.last_exchange().map(|exchange| exchange.id)
);
assert!(conversation.contains_action(&action.id));
});
});
}
#[test]
fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
App::test((), |mut app| async move {
@@ -193,6 +367,125 @@ fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
});
}
#[test]
fn completed_command_assessment_survives_cli_subtask_deactivation_on_root() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let block_id = BlockId::new();
let assessment_output = "The command completed successfully.";
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let monitor_exchange =
create_exchange_with_query("Check the command status.", Local::now(), None);
let monitor_exchange_id = monitor_exchange.id;
let conversation = model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
conversation
.append_task_exchange_for_test(
&cli_task_id,
monitor_exchange,
terminal_view_id,
ctx,
)
.expect("monitor exchange should be appended to the CLI task");
let now = Local::now();
let assessment_exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Assess the completed command.".to_string(),
context: Arc::from([]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}],
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Success {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("assessment-output".to_string()),
message: AIAgentOutputMessageType::Text(AIAgentText {
sections: vec![AIAgentTextSection::PlainText {
text: AgentOutputText::from(assessment_output.to_string()),
}],
}),
citations: vec![],
}],
..Default::default()
}),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
response_initiator: None,
};
let assessment_exchange_id = assessment_exchange.id;
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(assessment_exchange);
model
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
.expect("CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI task should be retained after deactivation");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
cli_task.last_exchange().map(|exchange| exchange.id),
Some(monitor_exchange_id)
);
let root_exchange = conversation
.latest_visible_exchange()
.expect("root assessment output should remain visible");
assert_eq!(root_exchange.id, assessment_exchange_id);
assert!(matches!(
root_exchange.input.as_slice(),
[AIAgentInput::CommandCompletionAssessment { .. }]
));
assert!(root_exchange.input[0].display_query().is_none());
assert_eq!(
root_exchange.format_output_for_copy(None),
assessment_output
);
});
});
}
#[test]
fn monitoring_a_different_block_preserves_completed_cli_task_history() {
App::test((), |mut app| async move {
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
};
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
if event.action_id() != me.action_id() {
if event.action_id() != me.action_id()
|| event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.conversation_id)
{
return;
}
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
/// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
let action_model = self.action_model.as_ref(app);
if let Some(status) = action_model.get_action_status(self.action_id()) {
if let Some(status) = action_model.get_action_status(self.conversation_id, self.action_id())
{
return Some(status);
}
@@ -366,7 +366,7 @@ pub enum CodeDiffState {
/// The diff is received, but is queued for interaction behind another action.
Queued,
/// The user is reviewing (and possibly editing) the code diff.
/// Unlike requested commands, a [`CodeDiffView`] is only created upon stream completion.
/// The view is created as soon as the requested edit is present in streaming output.
WaitingForUser,
/// If the payload is some, the code diff was accepted but the individual file changes have not
/// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs
@@ -695,12 +695,26 @@ impl CodeDiffView {
session_platform,
ctx,
);
let action_id = (*action_id).clone();
ctx.subscribe_to_model(
&action_model,
move |me, action_model, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => {
match action_model.as_ref(ctx).get_action_status(&me.action_id) {
BlocklistAIActionEvent::FinishedAction {
action_id: event_action_id,
conversation_id: event_conversation_id,
..
} if !me.is_complete()
&& *event_action_id == me.action_id
&& me.identifiers.client_conversation_id == Some(*event_conversation_id) =>
{
let Some(conversation_id) = me.identifiers.client_conversation_id else {
return;
};
match action_model
.as_ref(ctx)
.get_action_status(conversation_id, &me.action_id)
{
Some(AIActionStatus::Blocked) => {
me.state = CodeDiffState::WaitingForUser;
ctx.notify();
@@ -16,5 +16,6 @@ pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(crate) mod suggested_unit_tests;
pub(super) mod summarization;
pub(crate) mod tool_pane;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -175,30 +175,32 @@ impl OrchestrationEditState {
self.model_id.clear();
}
}
pub fn from_run_agents_fields(
model_id: &str,
harness_type: &str,
execution_mode: &RunAgentsExecutionMode,
) -> Self {
Self {
let execution_mode = match execution_mode {
RunAgentsExecutionMode::Local | RunAgentsExecutionMode::Remote { .. } => {
RunAgentsExecutionMode::Local
}
};
let mut state = Self {
model_id: model_id.to_string(),
harness_type: harness_type.to_string(),
execution_mode: execution_mode.clone(),
execution_mode,
auth_secret_selection: AuthSecretSelection::Unset,
}
};
state.sanitize_for_local_execution();
state
}
pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self {
let execution_mode = match &config.execution_mode {
OrchestrationExecutionMode::Local => RunAgentsExecutionMode::Local,
OrchestrationExecutionMode::Remote {
environment_id,
worker_host,
} => RunAgentsExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
computer_use_enabled: false,
},
OrchestrationExecutionMode::Local | OrchestrationExecutionMode::Remote { .. } => {
RunAgentsExecutionMode::Local
}
};
let mut state = Self {
model_id: config.model_id.clone(),
@@ -206,30 +208,17 @@ impl OrchestrationEditState {
execution_mode,
auth_secret_selection: AuthSecretSelection::Unset,
};
if matches!(state.execution_mode, RunAgentsExecutionMode::Local) {
state.sanitize_for_local_execution();
}
state.sanitize_for_local_execution();
state
}
/// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching
/// to Cloud (unsupported combination).
/// Galaxy only supports local child agents, so any mode selection is normalized to Local.
pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) {
if is_remote {
if self.harness_type.eq_ignore_ascii_case("opencode") {
self.harness_type = "oz".to_string();
}
if !self.execution_mode.is_remote() {
self.execution_mode = RunAgentsExecutionMode::Remote {
environment_id: String::new(),
worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(),
computer_use_enabled: false,
};
}
} else {
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
log::warn!("Ignoring remote orchestration selection because Galaxy is local-only");
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
pub fn set_environment_id(&mut self, environment_id: String) {
@@ -251,27 +240,17 @@ impl OrchestrationEditState {
}
/// Returns `Some(reason)` if Accept / Apply must be disabled.
/// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses.
pub fn accept_disabled_reason(&self) -> Option<&'static str> {
match &self.execution_mode {
RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type)
.and_then(local_harness_product_disabled_message),
RunAgentsExecutionMode::Remote { .. }
if self.harness_type.eq_ignore_ascii_case("opencode") =>
{
Some(
"OpenCode is not supported on Cloud yet. Switch to Local or pick a different harness.",
)
RunAgentsExecutionMode::Remote { .. } => {
Some("Galaxy only supports local child-agent orchestration.")
}
RunAgentsExecutionMode::Remote { .. } => None,
}
}
/// Fills in empty fields from the approved orchestration config.
/// When the LLM omits harness/model/execution_mode to inherit from
/// the active config, the raw request arrives with defaults (empty
/// harness, empty model, Local mode). This resolves those to the
/// config values so the UI shows the intended settings.
/// Fills empty model and harness fields from the approved config while keeping execution local.
pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) {
if self.harness_type.is_empty() && !config.harness_type.is_empty() {
self.harness_type = config.harness_type.clone();
@@ -279,67 +258,24 @@ impl OrchestrationEditState {
if self.model_id.is_empty() && !config.model_id.is_empty() {
self.model_id = config.model_id.clone();
}
if !self.execution_mode.is_remote() && config.execution_mode.is_remote() {
self.execution_mode = Self::from_orchestration_config(config).execution_mode;
}
if matches!(self.execution_mode, RunAgentsExecutionMode::Local) {
self.sanitize_for_local_execution();
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
/// Unconditionally overrides model, harness, and execution mode
/// from the approved orchestration config. The plan config is the
/// user-approved source of truth — the LLM's run_agents call may
/// omit or set these differently, but the config always wins.
///
/// `computer_use_enabled` is preserved from the current state when
/// both sides are Remote, since it is a per-call flag set by the LLM.
/// Applies the approved model and harness while keeping execution local.
pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) {
self.model_id = config.model_id.clone();
self.harness_type = config.harness_type.clone();
let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) {
(
RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
},
OrchestrationExecutionMode::Remote { .. },
) => Some(*computer_use_enabled),
_ => None,
};
self.execution_mode = Self::from_orchestration_config(config).execution_mode;
if let (
Some(cue),
RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
},
) = (preserve_computer_use, &mut self.execution_mode)
{
*computer_use_enabled = cue;
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
/// Converts to a native `OrchestrationConfig` for storage / match.
/// Converts to a local-only native `OrchestrationConfig` for storage / match.
pub fn to_orchestration_config(&self) -> OrchestrationConfig {
let execution_mode = match &self.execution_mode {
RunAgentsExecutionMode::Local => OrchestrationExecutionMode::Local,
RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
..
} => OrchestrationExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
},
};
OrchestrationConfig {
model_id: self.model_id.clone(),
harness_type: self.harness_type.clone(),
execution_mode,
execution_mode: OrchestrationExecutionMode::Local,
}
}
}
@@ -360,7 +296,6 @@ pub struct OrchestrationPickerHandles<A: OrchestrationControlAction> {
/// auth-secret types.
pub auth_secret_picker: Option<ViewHandle<Dropdown<A>>>,
pub local_toggle: MouseStateHandle,
pub cloud_toggle: MouseStateHandle,
}
impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
@@ -372,7 +307,6 @@ impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
host_picker: None,
auth_secret_picker: None,
local_toggle: MouseStateHandle::default(),
cloud_toggle: MouseStateHandle::default(),
}
}
}
@@ -1805,7 +1739,6 @@ impl Element for AdaptivePickerRow {
// ── Render helpers ──────────────────────────────────────────────────
pub fn render_mode_toggle<A: OrchestrationControlAction>(
is_remote: bool,
handles: &OrchestrationPickerHandles<A>,
appearance: &Appearance,
active_segment_bg: Option<Fill>,
@@ -1822,27 +1755,18 @@ pub fn render_mode_toggle<A: OrchestrationControlAction>(
let local_segment = render_segment_button::<A>(
"Local",
!is_remote,
true,
A::execution_mode_toggled(false),
handles.local_toggle.clone(),
appearance,
active_segment_bg,
);
let cloud_segment = render_segment_button::<A>(
"Cloud",
is_remote,
A::execution_mode_toggled(true),
handles.cloud_toggle.clone(),
appearance,
active_segment_bg,
);
let segment_outer_bg = galaxy_core::ui::theme::color::internal_colors::fg_overlay_2(theme);
let segments_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Expanded::new(1.0, cloud_segment).finish())
.with_child(Expanded::new(1.0, local_segment).finish())
.finish();
let segmented_control = Container::new(segments_row)
@@ -6,18 +6,6 @@ use super::{
OrchestrationEditState,
};
fn remote_claude_state() -> OrchestrationEditState {
OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
)
}
fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model_id.to_string(),
@@ -26,10 +14,44 @@ fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
}
}
fn remote_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model_id.to_string(),
harness_type: harness_type.to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
}
}
fn remote_mode() -> RunAgentsExecutionMode {
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
}
}
#[test]
fn from_orchestration_config_preserves_local_claude() {
fn run_agents_remote_mode_is_normalized_to_local() {
let state = OrchestrationEditState::from_run_agents_fields("sonnet", "claude", &remote_mode());
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(should_show_harness_picker(&state));
assert!(!should_show_auth_secret_picker(&state));
}
#[test]
fn remote_orchestration_config_is_normalized_to_local() {
let state =
OrchestrationEditState::from_orchestration_config(&local_config("claude", "sonnet"));
OrchestrationEditState::from_orchestration_config(&remote_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
@@ -39,66 +61,24 @@ fn from_orchestration_config_preserves_local_claude() {
}
#[test]
fn harness_picker_stays_visible_for_local_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
fn remote_toggle_remains_local() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Local,
);
assert!(should_show_harness_picker(&state));
}
#[test]
fn harness_picker_stays_visible_for_remote_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(true);
assert!(should_show_harness_picker(&state));
}
#[test]
fn from_orchestration_config_preserves_remote_claude() {
let state = OrchestrationEditState::from_orchestration_config(&OrchestrationConfig {
model_id: "sonnet".to_string(),
harness_type: "claude".to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
});
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Remote {
ref environment_id,
ref worker_host,
computer_use_enabled: false,
} if environment_id == "env-1" && worker_host == "warp"
RunAgentsExecutionMode::Local
));
}
#[test]
fn toggle_to_local_sanitizes_disabled_codex() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"gpt-5",
"codex",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(false);
fn local_normalization_sanitizes_disabled_harnesses() {
let state = OrchestrationEditState::from_run_agents_fields("gpt-5", "codex", &remote_mode());
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
@@ -109,18 +89,11 @@ fn toggle_to_local_sanitizes_disabled_codex() {
}
#[test]
fn toggle_to_local_preserves_claude() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
fn resolve_from_remote_config_inherits_fields_but_stays_local() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.toggle_execution_mode_to_remote(false);
state.resolve_from_config(&remote_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
@@ -131,27 +104,21 @@ fn toggle_to_local_preserves_claude() {
}
#[test]
fn accept_disabled_reason_allows_local_claude_product() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"claude",
&RunAgentsExecutionMode::Local,
);
assert_eq!(state.accept_disabled_reason(), None);
}
fn approved_remote_config_override_stays_local() {
let mut state = OrchestrationEditState::from_run_agents_fields("auto", "oz", &remote_mode());
#[test]
fn resolve_from_config_preserves_local_claude() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.override_from_approved_config(&remote_config("claude", "sonnet"));
state.resolve_from_config(&local_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(matches!(
state.to_orchestration_config().execution_mode,
OrchestrationExecutionMode::Local
));
}
#[test]
@@ -163,32 +130,29 @@ fn resolve_from_config_sanitizes_disabled_local_codex() {
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(state.accept_disabled_reason(), None);
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_named() {
let mut state = remote_claude_state();
fn local_mode_does_not_expose_managed_auth_secret() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string());
assert_eq!(state.auth_secret_name(), Some("my-key"));
state.select_create_new_auth_secret();
// `CreatingNew` (distinct from `Unset`) blocks Accept and isn't re-seeded.
assert!(matches!(
state.auth_secret_selection,
AuthSecretSelection::CreatingNew
));
assert_eq!(state.auth_secret_name(), None);
assert!(should_show_auth_secret_picker(&state));
assert!(!should_show_auth_secret_picker(&state));
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_inherit() {
let mut state = remote_claude_state();
fn selecting_create_auth_secret_remains_a_distinct_state() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Inherit;
state.select_create_new_auth_secret();
@@ -36,13 +36,13 @@ use crate::ai::blocklist::block::cli_controller::{
use crate::ai::blocklist::block::view_impl::output::action_icon;
use crate::ai::blocklist::block::view_impl::{
render_autonomy_checkbox_setting_speedbump_footer, render_citation, render_citation_chips,
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
use crate::ai::blocklist::block::{AIBlockAction, AutonomySettingSpeedbump};
use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
AIBlock, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel,
@@ -412,19 +412,23 @@ impl RequestedCommandView {
let is_finished = action_model
.as_ref(ctx)
.get_action_result(&action_id)
.get_action_result(client_ids.conversation_id, &action_id)
.is_some();
if !is_finished {
ctx.subscribe_to_model(action_model, |me, _, event, ctx| {
match event {
BlocklistAIActionEvent::QueuedAction(action_id)
BlocklistAIActionEvent::QueuedAction { action_id, .. }
if *action_id == me.action_id =>
{
ctx.notify();
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
if *action_id == me.action_id =>
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
if me.action_type.is_requested_command() {
me.ensure_editor(ctx);
@@ -432,8 +436,12 @@ impl RequestedCommandView {
me.set_is_header_expanded(true, ctx);
ctx.notify();
}
BlocklistAIActionEvent::ExecutingAction(action_id)
if *action_id == me.action_id =>
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
// For shared-session viewers, sync the command text from the action when it starts executing.
if me.action_model.as_ref(ctx).is_view_only() {
@@ -467,11 +475,15 @@ impl RequestedCommandView {
}
ctx.notify();
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id => {
let Some(action_result) = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.cloned()
else {
log::info!("Got finished action event without result: {action_id}.");
@@ -724,7 +736,7 @@ impl RequestedCommandView {
fn is_waiting_for_user_confirmation(&self, app: &AppContext) -> bool {
self.action_model
.as_ref(app)
.get_action_status(&self.action_id)
.get_action_status(self.client_ids.conversation_id, &self.action_id)
.is_some_and(|status| status.is_blocked())
}
@@ -750,7 +762,9 @@ impl RequestedCommandView {
let Some(mouse_state_handle) =
self.citation_state_handles.get(copied_citation).cloned()
else {
log::warn!("Tried to retrieve mouse state handle for citation, but no mouse state handle exists.");
log::warn!(
"Tried to retrieve mouse state handle for citation, but no mouse state handle exists."
);
return None;
};
render_citation(
@@ -1108,7 +1122,7 @@ impl RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let mut title: Cow<'static, str>;
let mut font_override = None;
@@ -1457,7 +1471,7 @@ impl View for RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let is_last_output_message_in_output = self
.block_model
@@ -1600,14 +1614,9 @@ impl View for RequestedCommandView {
content.add_child(Clipped::new(footer).finish());
}
let border_color = if action_status
let has_highlighted_border = action_status
.as_ref()
.is_some_and(|status| status.is_blocked())
{
theme.accent()
} else {
theme.surface_2()
};
.is_some_and(|status| status.is_blocked());
// If the requested command is expanded above a terminal block or
// the next exchange flows directly after, remove bottom margin for
@@ -1637,21 +1646,13 @@ impl View for RequestedCommandView {
}))
&& !is_input_pinned_to_top);
let container = Container::new(content.finish())
.with_margin_left(if action_status.is_some_and(|status| status.is_blocked()) {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
})
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(if should_remove_bottom_margin {
0.
} else {
CONTENT_ITEM_VERTICAL_MARGIN
})
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish();
let container = render_tool_pane_shell(
content.finish(),
has_highlighted_border,
self.is_header_expanded,
should_remove_bottom_margin,
app,
);
let mut root_stack = Stack::new();
root_stack.add_child(container);
@@ -7,14 +7,15 @@ use std::collections::HashMap;
use std::rc::Rc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult};
use ai::agent::action_result::{RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsResult};
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
use ai::skills::SkillReference;
use galaxy_core::send_telemetry_from_ctx;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Stack, Text, Wrap,
};
use warpui::keymap::FixedBinding;
use warpui::{
@@ -22,12 +23,16 @@ use warpui::{
ViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
use crate::ai::agent::{icons, AIAgentActionId, AIAgentActionResultType};
use crate::ai::blocklist::action_model::{
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor,
RunAgentsExecutorEvent, RunAgentsSpawningSnapshot,
};
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
dispatch_focus_or_open_child_agent_pane,
};
use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agent_pill;
use crate::ai::blocklist::block::model::AIBlockModel;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
@@ -50,6 +55,7 @@ use crate::ai::blocklist::telemetry::{
OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision,
RunAgentsCardDecisionEvent,
};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::connected_self_hosted_workers::{
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
};
@@ -139,7 +145,7 @@ impl RunAgentsEditState {
skills: self.skills.clone(),
model_id: self.orch.model_id.clone(),
harness_type: self.orch.harness_type.clone(),
execution_mode: self.orch.execution_mode.clone(),
execution_mode: RunAgentsExecutionMode::Local,
agent_run_configs: self.agent_run_configs.clone(),
plan_id: self.plan_id.clone(),
harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string),
@@ -213,11 +219,115 @@ pub enum RunAgentsCardViewEvent {
RejectRequested,
}
#[derive(Clone)]
struct RunAgentsChildState {
name: String,
conversation_id: Option<AIConversationId>,
removed: bool,
mouse_state: MouseStateHandle,
}
impl RunAgentsChildState {
fn new(name: String) -> Self {
Self {
name,
conversation_id: None,
removed: false,
mouse_state: MouseStateHandle::default(),
}
}
}
fn sync_run_agents_children(
children: &mut Vec<RunAgentsChildState>,
configs: &[RunAgentsAgentRunConfig],
) {
let mut previous_children = std::mem::take(children);
*children = configs
.iter()
.map(|config| {
previous_children
.iter()
.position(|child| child.name == config.name)
.map(|index| previous_children.remove(index))
.unwrap_or_else(|| RunAgentsChildState::new(config.name.clone()))
})
.collect();
}
fn link_run_agents_child(
children: &mut [RunAgentsChildState],
agent_name: &str,
conversation_id: AIConversationId,
) -> bool {
let child_index = children
.iter()
.position(|child| child.name == agent_name && child.conversation_id.is_none())
.or_else(|| children.iter().position(|child| child.name == agent_name));
let Some(child_index) = child_index else {
return false;
};
let child = &mut children[child_index];
child.conversation_id = Some(conversation_id);
child.removed = false;
true
}
fn has_run_agents_child(
children: &[RunAgentsChildState],
conversation_id: AIConversationId,
) -> bool {
children
.iter()
.any(|child| child.conversation_id == Some(conversation_id))
}
fn mark_run_agents_child_removed(
children: &mut [RunAgentsChildState],
conversation_id: AIConversationId,
) -> bool {
let Some(child) = children
.iter_mut()
.find(|child| child.conversation_id == Some(conversation_id))
else {
return false;
};
child.removed = true;
true
}
fn run_agents_event_matches_card(
event: &RunAgentsExecutorEvent,
conversation_id: Option<AIConversationId>,
action_id: &AIAgentActionId,
) -> bool {
let (event_conversation_id, event_action_id) = match event {
RunAgentsExecutorEvent::SpawningStarted {
conversation_id,
action_id,
..
}
| RunAgentsExecutorEvent::SpawningFinished {
conversation_id,
action_id,
} => (*conversation_id, action_id),
RunAgentsExecutorEvent::ChildConversationCreated {
action_id,
parent_conversation_id,
..
} => (*parent_conversation_id, action_id),
};
Some(event_conversation_id) == conversation_id && event_action_id == action_id
}
pub struct RunAgentsCardView {
action_id: AIAgentActionId,
state: RunAgentsEditState,
handles: RunAgentsCardHandles,
spawning: Option<RunAgentsSpawningSnapshot>,
children: Vec<RunAgentsChildState>,
terminal_view_id: warpui::EntityId,
/// Retained for interactive defaults and telemetry about plan-sourced
/// orchestration state.
active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>,
@@ -303,6 +413,12 @@ impl RunAgentsCardView {
ctx: &mut ViewContext<Self>,
) -> Self {
let state = RunAgentsEditState::from_request(request);
let children = state
.agent_run_configs
.iter()
.map(|config| RunAgentsChildState::new(config.name.clone()))
.collect();
let terminal_view_id = run_agents_executor.as_ref(ctx).terminal_view_id();
// Snapshot the raw incoming request so we can diff against the
// edited state at Accept time.
let original_tool_call_request = request.clone();
@@ -350,49 +466,86 @@ impl RunAgentsCardView {
});
let action_id_for_subscription = action_id.clone();
ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event {
RunAgentsExecutorEvent::SpawningStarted {
action_id,
snapshot,
} if action_id == &action_id_for_subscription => {
me.spawning = Some(*snapshot);
let conversation_id_for_subscription = block_model.conversation_id(ctx);
ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| {
if !run_agents_event_matches_card(
event,
conversation_id_for_subscription,
&action_id_for_subscription,
) {
return;
}
match event {
RunAgentsExecutorEvent::SpawningStarted { snapshot, .. } => {
me.spawning = Some(*snapshot);
ctx.notify();
}
RunAgentsExecutorEvent::SpawningFinished { .. } => {
me.spawning = None;
ctx.notify();
}
RunAgentsExecutorEvent::ChildConversationCreated {
agent_name,
child_conversation_id,
..
} => {
me.link_child_conversation(agent_name, *child_conversation_id);
ctx.notify();
}
}
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} if me.has_child_conversation(*conversation_id) => {
ctx.notify();
}
RunAgentsExecutorEvent::SpawningFinished { action_id }
if action_id == &action_id_for_subscription =>
{
me.spawning = None;
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} if me.mark_child_removed(*conversation_id) => {
ctx.notify();
}
RunAgentsExecutorEvent::SpawningStarted { .. }
| RunAgentsExecutorEvent::SpawningFinished { .. } => {}
_ => {}
});
// Re-render when this action finishes or becomes blocked.
let action_id_for_action_events = action_id.clone();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| {
if event.conversation_id().is_some_and(|conversation_id| {
me.block_model.conversation_id(ctx) != Some(conversation_id)
}) {
return;
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
}
ctx.notify();
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
}
ctx.notify();
}
_ => {}
}
_ => {}
});
// Repopulate the model picker when available Warp LLMs change.
@@ -481,6 +634,8 @@ impl RunAgentsCardView {
..Default::default()
},
spawning: None,
children,
terminal_view_id,
active_config,
is_accept_menu_open: false,
accept_menu,
@@ -543,6 +698,7 @@ impl RunAgentsCardView {
|| self.state.orch.model_id != new_state.orch.model_id
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
self.state = new_state;
self.sync_configured_children();
if harness_or_model_changed {
// Repopulate pickers and re-arm auto-open for the newly-
// streamed harness.
@@ -555,6 +711,26 @@ impl RunAgentsCardView {
}
}
fn sync_configured_children(&mut self) {
sync_run_agents_children(&mut self.children, &self.state.agent_run_configs);
}
fn link_child_conversation(&mut self, agent_name: &str, conversation_id: AIConversationId) {
if !link_run_agents_child(&mut self.children, agent_name, conversation_id) {
log::warn!(
"RunAgentsCardView: received child conversation for unknown agent '{agent_name}'"
);
}
}
fn has_child_conversation(&self, conversation_id: AIConversationId) -> bool {
has_run_agents_child(&self.children, conversation_id)
}
fn mark_child_removed(&mut self, conversation_id: AIConversationId) -> bool {
mark_run_agents_child_removed(&mut self.children, conversation_id)
}
/// Validates and dispatches the resolved request.
pub fn accept(&mut self, ctx: &mut ViewContext<Self>) {
self.handle_accept(ctx);
@@ -571,8 +747,11 @@ impl RunAgentsCardView {
let request = self.state.to_request();
self.emit_decision(RunAgentsCardDecision::Accept, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.execute_run_agents(&action_id, request, action_ctx);
action_model.execute_run_agents(conversation_id, &action_id, request, action_ctx);
});
}
@@ -664,10 +843,13 @@ impl RunAgentsCardView {
if self.block_model.is_restored() {
return;
}
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
if matches!(
self.action_model
.as_ref(ctx)
.get_action_status(&self.action_id),
.get_action_status(conversation_id, &self.action_id),
Some(AIActionStatus::Finished(_)) | Some(AIActionStatus::RunningAsync)
) {
return;
@@ -951,13 +1133,23 @@ impl View for RunAgentsCardView {
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.block_model
.conversation_id(app)
.and_then(|conversation_id| {
self.action_model
.as_ref(app)
.get_action_status(conversation_id, &self.action_id)
});
if let Some(AIActionStatus::Finished(result)) = &status {
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
return render_terminal_state(orchestrate_result, appearance, app);
return render_terminal_state(
orchestrate_result,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
log::error!(
"Unexpected action result type for orchestrate: {:?}",
@@ -969,13 +1161,25 @@ impl View for RunAgentsCardView {
// In-flight dispatch: check both spawning snapshot and action
// status because the event arrives one tick after the status.
if let Some(snapshot) = &self.spawning {
return render_spawning_card(snapshot, appearance, app);
return render_spawning_card(
snapshot,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
if matches!(status, Some(AIActionStatus::RunningAsync)) {
let snapshot = RunAgentsSpawningSnapshot {
agent_count: self.state.agent_run_configs.len(),
};
return render_spawning_card(&snapshot, appearance, app);
return render_spawning_card(
&snapshot,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
// Restored-from-history: dispatch state is lost, render as
@@ -1048,8 +1252,16 @@ impl TypedActionView for RunAgentsCardView {
RunAgentsCardViewAction::AcceptWithoutOrchestration => {
self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.deny_run_agents(&action_id, String::new(), action_ctx);
action_model.deny_run_agents(
conversation_id,
&action_id,
String::new(),
action_ctx,
);
});
}
RunAgentsCardViewAction::ToggleAcceptMenu => {
@@ -1352,11 +1564,21 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box<dy
fn render_terminal_state(
result: &RunAgentsResult,
children: &[RunAgentsChildState],
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let (label, kind) = format_terminal_state(result);
render_status_only_card(label, appearance, kind, app)
render_status_card(
label,
appearance,
kind,
children,
Some(result),
Some(terminal_view_id),
app,
)
}
pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) {
@@ -1367,14 +1589,31 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
.count();
if launched == total {
let completed = agents
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Completed { .. }))
.count();
let successful = launched + completed;
if completed > 0 && completed == total {
let label = if total == 1 {
"Completed 1 agent".to_string()
} else {
format!("Completed {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 && completed > 0 {
(
format!("Completed {completed} of {total} agents"),
StatusKind::Mixed,
)
} else if successful == total {
let label = if total == 1 {
"Spawned 1 agent".to_string()
} else {
format!("Spawned {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 {
} else if successful == 0 {
// Every child failed to launch: surface a terminal failure
// rather than the in-progress-looking mixed state.
let label = if total == 1 {
@@ -1385,7 +1624,7 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
(label, StatusKind::Failure)
} else {
(
format!("Spawned {launched} of {total} agents"),
format!("Spawned {successful} of {total} agents"),
StatusKind::Mixed,
)
}
@@ -1424,6 +1663,8 @@ pub(crate) enum StatusKind {
fn render_spawning_card(
snapshot: &RunAgentsSpawningSnapshot,
children: &[RunAgentsChildState],
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
@@ -1433,7 +1674,15 @@ fn render_spawning_card(
} else {
format!("Spawning {total} agents\u{2026}")
};
render_status_only_card(label, appearance, StatusKind::Spawning, app)
render_status_card(
label,
appearance,
StatusKind::Spawning,
children,
None,
Some(terminal_view_id),
app,
)
}
fn render_status_only_card(
@@ -1441,6 +1690,19 @@ fn render_status_only_card(
appearance: &Appearance,
kind: StatusKind,
app: &AppContext,
) -> Box<dyn Element> {
render_status_card(label, appearance, kind, &[], None, None, app)
}
#[allow(clippy::too_many_arguments)]
fn render_status_card(
label: String,
appearance: &Appearance,
kind: StatusKind,
children: &[RunAgentsChildState],
result: Option<&RunAgentsResult>,
terminal_view_id: Option<warpui::EntityId>,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let icon = match kind {
@@ -1452,7 +1714,8 @@ fn render_status_only_card(
StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(),
StatusKind::Cancelled => inline_action_icons::cancelled_icon(appearance).finish(),
};
let row = render_requested_action_row_for_text(
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_requested_action_row_for_text(
label.into(),
appearance.ui_font_family(),
Some(icon),
@@ -1460,8 +1723,49 @@ fn render_status_only_card(
false,
false,
app,
);
Container::new(row)
));
if !children.is_empty() {
let Some(terminal_view_id) = terminal_view_id else {
log::error!("RunAgentsCardView: child rows require a terminal view id");
return Empty::new().finish();
};
let outcomes = match result {
Some(RunAgentsResult::Launched { agents, .. }) => Some(agents.as_slice()),
Some(
RunAgentsResult::Denied { .. }
| RunAgentsResult::Failure { .. }
| RunAgentsResult::Cancelled,
)
| None => None,
};
let mut child_column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, child) in children.iter().enumerate() {
let outcome = outcomes.and_then(|agents| agents.get(index));
child_column.add_child(
Container::new(render_run_agents_child_row(
child,
outcome,
result.is_some(),
terminal_view_id,
appearance,
app,
))
.with_margin_top(4.)
.finish(),
);
}
column.add_child(
Container::new(child_column.finish())
.with_padding_left(8.)
.with_padding_right(8.)
.with_padding_bottom(8.)
.finish(),
);
}
Container::new(column.finish())
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
@@ -1469,6 +1773,93 @@ fn render_status_only_card(
.finish()
}
fn render_run_agents_child_row(
child: &RunAgentsChildState,
outcome: Option<&RunAgentsAgentOutcome>,
is_terminal: bool,
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id }
| RunAgentsAgentOutcomeKind::Completed { agent_id, .. } => {
conversation_id_for_agent_id(agent_id, app)
}
RunAgentsAgentOutcomeKind::Failed { .. } => None,
});
let conversation_id = child.conversation_id.or(outcome_conversation_id);
if !child.removed {
if let Some(conversation_id) = conversation_id {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
{
let status = conversation.status();
let status_icon =
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
let mouse_state = child.mouse_state.clone();
return conversation_navigation_card_with_icon(
Some(status_icon),
child.name.clone(),
Some(status.to_string()),
move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
conversation_id,
terminal_view_id,
ctx,
app,
);
},
mouse_state,
true,
None,
app,
);
}
}
}
let (status, label) = if child.removed {
(ConversationStatus::Cancelled, "Removed".to_string())
} else if let Some(outcome) = outcome {
match &outcome.kind {
RunAgentsAgentOutcomeKind::Launched { .. } => {
(ConversationStatus::Success, "Started".to_string())
}
RunAgentsAgentOutcomeKind::Completed { .. } => {
(ConversationStatus::Success, "Completed".to_string())
}
RunAgentsAgentOutcomeKind::Failed { error } => (
ConversationStatus::Error,
if error.trim().is_empty() {
"Failed".to_string()
} else {
format!("Failed: {error}")
},
),
}
} else if is_terminal {
(ConversationStatus::Cancelled, "Not started".to_string())
} else {
(
ConversationStatus::InProgress,
"Starting\u{2026}".to_string(),
)
};
let (icon, color) =
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
render_requested_action_row_for_text(
format!("{}: {label}", child.name).into(),
appearance.ui_font_family(),
Some(icon.to_warpui_icon(color.into()).finish()),
None,
false,
false,
app,
)
}
fn render_editor(
state: &RunAgentsEditState,
handles: &RunAgentsCardHandles,
@@ -1490,7 +1881,6 @@ fn render_editor(
column.add_child(
Container::new(oc::render_mode_toggle(
state.orch.execution_mode.is_remote(),
&handles.pickers,
appearance,
None,
@@ -8,7 +8,13 @@ use ai::agent::action_result::{
use ai::skills::SkillReference;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::RunAgentsEditState;
use super::{
has_run_agents_child, link_run_agents_child, mark_run_agents_child_removed,
run_agents_event_matches_card, sync_run_agents_children, RunAgentsChildState,
RunAgentsEditState, RunAgentsExecutorEvent,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentActionId;
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest {
@@ -57,30 +63,20 @@ fn make_edit_state_with_orch_fields(
}
#[test]
fn local_to_cloud_initializes_remote_with_empty_environment() {
fn remote_toggle_remains_local() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
computer_use_enabled,
} = state.orch.execution_mode
else {
panic!("expected Remote after toggle");
};
assert_eq!(environment_id, "");
assert_eq!(worker_host, "warp");
assert!(!computer_use_enabled);
}
#[test]
fn cloud_to_local_drops_environment() {
fn legacy_remote_request_normalizes_to_local() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -97,15 +93,21 @@ fn cloud_to_local_drops_environment() {
}
#[test]
fn local_to_cloud_resets_opencode_to_oz() {
fn remote_toggle_preserves_supported_local_harness() {
let mut state =
RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.harness_type, "opencode");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn cloud_without_env_no_longer_disables_accept() {
fn legacy_remote_request_without_environment_allows_local_acceptance() {
let state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -114,15 +116,15 @@ fn cloud_without_env_no_longer_disables_accept() {
computer_use_enabled: false,
},
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud without env should NOT disable Accept (soft recommendation only)"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(state.orch.accept_disabled_reason().is_none());
}
#[test]
fn cloud_with_opencode_disables_accept() {
// Bypass the toggle helper to test the validation gate directly.
fn legacy_remote_opencode_request_is_normalized_and_allowed_locally() {
let state = RunAgentsEditState::from_request(&make_request(
"opencode",
RunAgentsExecutionMode::Remote {
@@ -131,9 +133,12 @@ fn cloud_with_opencode_disables_accept() {
computer_use_enabled: false,
},
));
let reason = state.orch.accept_disabled_reason();
assert!(reason.is_some(), "Cloud + OpenCode should disable Accept");
assert!(reason.unwrap().contains("OpenCode"));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(state.orch.accept_disabled_reason(), None);
}
#[test]
@@ -149,12 +154,11 @@ fn local_with_any_harness_does_not_disable_accept() {
}
#[test]
fn local_with_disabled_codex_disables_accept() {
fn local_with_disabled_codex_is_sanitized() {
let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local);
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.accept_disabled_reason(), None);
}
#[test]
@@ -168,7 +172,7 @@ fn from_request_sanitizes_disabled_local_harness_to_oz() {
}
#[test]
fn cloud_with_env_and_non_opencode_harness_allows_accept() {
fn legacy_remote_harnesses_normalize_and_allow_local_acceptance() {
for harness in ["oz", "claude", "gemini"] {
let state = RunAgentsEditState::from_request(&make_request(
harness,
@@ -178,9 +182,13 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() {
computer_use_enabled: false,
},
));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud + env + {harness} should allow Accept"
"normalized local + {harness} should allow Accept"
);
}
}
@@ -197,7 +205,7 @@ fn set_environment_id_no_op_in_local_mode() {
}
#[test]
fn set_environment_id_updates_remote() {
fn set_environment_id_is_ignored_for_normalized_remote_request() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -206,15 +214,17 @@ fn set_environment_id_updates_remote() {
computer_use_enabled: false,
},
));
state.orch.set_environment_id("new-env".to_string());
let RunAgentsExecutionMode::Remote { environment_id, .. } = state.orch.execution_mode else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn to_request_round_trips_request_fields() {
fn to_request_preserves_fields_but_normalizes_execution_to_local() {
let mut req = make_request_with_skills(
"claude",
RunAgentsExecutionMode::Remote {
@@ -232,16 +242,96 @@ fn to_request_round_trips_request_fields() {
req.plan_id = "plan-1".to_string();
let state = RunAgentsEditState::from_request(&req);
let round_tripped = state.to_request();
assert_eq!(round_tripped.summary, req.summary);
assert_eq!(round_tripped.base_prompt, req.base_prompt);
assert_eq!(round_tripped.model_id, req.model_id);
assert_eq!(round_tripped.harness_type, req.harness_type);
assert_eq!(round_tripped.execution_mode, req.execution_mode);
assert!(matches!(
round_tripped.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(round_tripped.agent_run_configs, req.agent_run_configs);
assert_eq!(round_tripped.skills, req.skills);
assert_eq!(round_tripped.plan_id, req.plan_id);
}
#[test]
fn live_child_links_and_removal_survive_streaming_config_sync() {
let first_id = AIConversationId::new();
let replacement_id = AIConversationId::new();
let mut children = vec![
RunAgentsChildState::new("alpha".to_string()),
RunAgentsChildState::new("beta".to_string()),
];
assert!(link_run_agents_child(&mut children, "alpha", first_id));
assert!(has_run_agents_child(&children, first_id));
assert!(mark_run_agents_child_removed(&mut children, first_id));
assert!(children[0].removed);
let configs = vec![
RunAgentsAgentRunConfig {
name: "gamma".to_string(),
prompt: "new work".to_string(),
title: String::new(),
},
RunAgentsAgentRunConfig {
name: "alpha".to_string(),
prompt: "updated work".to_string(),
title: String::new(),
},
];
sync_run_agents_children(&mut children, &configs);
assert_eq!(
children
.iter()
.map(|child| child.name.as_str())
.collect::<Vec<_>>(),
vec!["gamma", "alpha"]
);
assert_eq!(children[1].conversation_id, Some(first_id));
assert!(children[1].removed);
assert!(link_run_agents_child(
&mut children,
"alpha",
replacement_id
));
assert_eq!(children[1].conversation_id, Some(replacement_id));
assert!(!children[1].removed);
assert!(!link_run_agents_child(
&mut children,
"missing",
AIConversationId::new()
));
}
#[test]
fn child_created_with_duplicate_action_id_only_matches_parent_conversation() {
let card_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let child_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_string());
let event = RunAgentsExecutorEvent::ChildConversationCreated {
action_id: duplicate_action_id.clone(),
agent_name: "child".to_string(),
parent_conversation_id: other_conversation_id,
child_conversation_id,
};
assert!(!run_agents_event_matches_card(
&event,
Some(card_conversation_id),
&duplicate_action_id,
));
assert!(run_agents_event_matches_card(
&event,
Some(other_conversation_id),
&duplicate_action_id,
));
}
mod format_terminal_state_tests {
use super::super::{format_terminal_state, StatusKind};
use super::*;
@@ -264,6 +354,16 @@ mod format_terminal_state_tests {
}
}
fn completed(name: &str, agent_id: &str) -> RunAgentsAgentOutcome {
RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: agent_id.to_string(),
output: format!("{name} output"),
},
}
}
fn launched_result(agents: Vec<RunAgentsAgentOutcome>) -> RunAgentsResult {
RunAgentsResult::Launched {
model_id: "auto".to_string(),
@@ -305,6 +405,30 @@ mod format_terminal_state_tests {
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_completed_uses_completed_label_and_success_status() {
let result = launched_result(vec![
completed("a", "a-1"),
completed("b", "a-2"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 3 agents");
assert!(matches!(kind, StatusKind::Success));
}
#[test]
fn mixed_completed_and_failed_uses_completed_label_and_mixed_status() {
let result = launched_result(vec![
completed("a", "a-1"),
failed("b", "boom"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 2 of 3 agents");
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_failed_uses_failure_status_not_mixed() {
let result = launched_result(vec![
@@ -414,34 +538,27 @@ mod override_from_approved_config_tests {
#[test]
fn overrides_even_when_request_has_values() {
let mut state = RunAgentsEditState::from_request(&make_request(
"claude",
RunAgentsExecutionMode::Local,
));
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&local_config("gpt-5", "codex"));
assert_eq!(state.orch.model_id, "gpt-5");
assert_eq!(state.orch.harness_type, "codex");
.override_from_approved_config(&local_config("sonnet", "claude"));
assert_eq!(state.orch.model_id, "sonnet");
assert_eq!(state.orch.harness_type, "claude");
}
#[test]
fn overrides_local_to_remote() {
fn remote_config_override_stays_local() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote after override");
};
assert_eq!(environment_id, "env-1");
assert_eq!(worker_host, "warp");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
@@ -464,7 +581,7 @@ mod override_from_approved_config_tests {
}
#[test]
fn preserves_computer_use_when_both_remote() {
fn remote_request_and_remote_override_drop_computer_use() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -476,57 +593,29 @@ mod override_from_approved_config_tests {
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "new-env"));
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env", "env should come from config");
assert!(
*computer_use_enabled,
"computer_use_enabled should be preserved from original request"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn does_not_carry_computer_use_from_local_to_remote() {
fn approved_local_disabled_harness_is_sanitized() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert!(
!*computer_use_enabled,
"computer_use_enabled should default to false when original was Local"
);
}
.override_from_approved_config(&local_config("gpt-5", "codex"));
#[test]
fn approved_local_disabled_harness_reports_disabled_reason_after_override() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&local_config("auto", "codex"));
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.model_id, "");
assert_eq!(state.orch.accept_disabled_reason(), None);
}
}
#[test]
fn local_to_cloud_idempotent_when_already_remote() {
fn remote_toggle_is_idempotently_local() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -535,21 +624,11 @@ fn local_to_cloud_idempotent_when_already_remote() {
computer_use_enabled: true,
},
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(
environment_id, "env-1",
"toggle to Remote when already Remote should not clobber env"
);
assert!(
computer_use_enabled,
"toggle to Remote when already Remote should not clobber computer_use"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
@@ -0,0 +1,43 @@
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::{Border, Container, CornerRadius, ParentElement, Radius};
use warpui::{AppContext, Element, SingletonEntity};
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::block::view_impl::{
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
/// Renders the shared outer shell used by native and runtime-owned tool panes.
///
/// Callers own execution and body content. This function owns the pane geometry
/// and theme treatment so display-only runtimes cannot drift from native tools.
pub(crate) fn render_tool_pane_shell(
content: Box<dyn Element>,
has_highlighted_border: bool,
spans_conversation_width: bool,
should_remove_bottom_margin: bool,
app: &AppContext,
) -> Box<dyn Element> {
let theme = Appearance::as_ref(app).theme();
let border_color = if has_highlighted_border {
theme.accent()
} else {
theme.surface_2()
};
Container::new(content)
.with_margin_left(if has_highlighted_border || spans_conversation_width {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
})
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(if should_remove_bottom_margin {
0.
} else {
CONTENT_ITEM_VERTICAL_MARGIN
})
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
@@ -469,6 +469,7 @@ impl OrchestrationEventService {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)

Some files were not shown because too many files have changed in this diff Show More