Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy
This commit is contained in:
@@ -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."
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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(¶ms, 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(¶ms, 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(¶ms, 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(
|
||||
¶ms,
|
||||
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(¶ms, 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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
@@ -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
@@ -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(¶ms, &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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(¶ms));
|
||||
let supported_cli_agent_tools =
|
||||
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(¶ms));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
|
||||
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(¶ms);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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:?}")]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ fn parse_claude_code_model_map(
|
||||
model_id: arn,
|
||||
display_name,
|
||||
vision_supported: true,
|
||||
use_rig: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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`"));
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
®ion,
|
||||
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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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, ¶ms.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(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
+1220
-152
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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| {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(_)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+4618
-441
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
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user