Add ACP agent backend and terminal controls
This commit is contained in:
@@ -306,6 +306,7 @@ app-installation-detection.workspace = true
|
||||
async-io.workspace = true
|
||||
axum.workspace = true
|
||||
cloud_object_persistence.workspace = true
|
||||
galaxy_acp.workspace = true
|
||||
comfy-table = "7.1.4"
|
||||
inquire = "0.9.1"
|
||||
diesel = { workspace = true, features = ["sqlite", "chrono"] }
|
||||
@@ -464,6 +465,7 @@ bundled_skills = []
|
||||
supergrok = []
|
||||
gemini_enterprise = []
|
||||
agent_mode = []
|
||||
agent_client_protocol = []
|
||||
agent_mode_computer_use = []
|
||||
background_computer_use = []
|
||||
agent_mode_debug = []
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::persistence::model::AcpConversationData;
|
||||
|
||||
pub(crate) fn acp_model_id(agent_id: &str) -> String {
|
||||
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn acp_launch_fingerprint(
|
||||
agent_id: &str,
|
||||
custom_command: &str,
|
||||
custom_args: &[String],
|
||||
) -> String {
|
||||
match resolve_acp_launch(agent_id, custom_command, custom_args) {
|
||||
Ok(launch) => effective_launch_fingerprint(
|
||||
agent_id,
|
||||
preset_version(agent_id, custom_command),
|
||||
&launch,
|
||||
true,
|
||||
),
|
||||
Err(_) => {
|
||||
let unresolved_launch = if !custom_command.trim().is_empty() {
|
||||
AcpLaunchConfig::new(custom_command.trim()).args(custom_args.iter().cloned())
|
||||
} else {
|
||||
match agent_id.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => AcpAgentPreset::Codex.launch_config(),
|
||||
"opencode" => AcpAgentPreset::OpenCode.launch_config(),
|
||||
_ => AcpLaunchConfig::new(""),
|
||||
}
|
||||
};
|
||||
effective_launch_fingerprint(
|
||||
agent_id,
|
||||
preset_version(agent_id, custom_command),
|
||||
&unresolved_launch,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_launch_fingerprint(
|
||||
agent_id: &str,
|
||||
preset_version: Option<&str>,
|
||||
launch: &AcpLaunchConfig,
|
||||
command_is_resolved: bool,
|
||||
) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"galaxy-acp-launch-v2");
|
||||
add_field(&mut hasher, &agent_id.trim().to_ascii_lowercase());
|
||||
add_field(
|
||||
&mut hasher,
|
||||
if command_is_resolved {
|
||||
"resolved"
|
||||
} else {
|
||||
"unresolved"
|
||||
},
|
||||
);
|
||||
add_field(&mut hasher, preset_version.unwrap_or(""));
|
||||
|
||||
let command = if command_is_resolved {
|
||||
std::fs::canonicalize(&launch.command).unwrap_or_else(|_| launch.command.clone())
|
||||
} else {
|
||||
launch.command.clone()
|
||||
};
|
||||
add_field(&mut hasher, &command.to_string_lossy());
|
||||
|
||||
add_count(&mut hasher, launch.args.len());
|
||||
for arg in &launch.args {
|
||||
add_field(&mut hasher, arg);
|
||||
}
|
||||
add_count(&mut hasher, launch.env.len());
|
||||
for (name, value) in &launch.env {
|
||||
add_field(&mut hasher, name);
|
||||
add_field(&mut hasher, value);
|
||||
}
|
||||
match launch.preferred_auth_method.as_ref() {
|
||||
Some(method) => {
|
||||
add_field(&mut hasher, "preferred-auth");
|
||||
add_field(&mut hasher, &method.to_string());
|
||||
}
|
||||
None => add_field(&mut hasher, "default-auth"),
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn add_field(hasher: &mut Sha256, value: &str) {
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
|
||||
fn add_count(hasher: &mut Sha256, count: usize) {
|
||||
hasher.update((count as u64).to_le_bytes());
|
||||
}
|
||||
|
||||
fn preset_version(agent_id: &str, custom_command: &str) -> Option<&'static str> {
|
||||
if !custom_command.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match agent_id.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => Some(CODEX_ACP_NPM_VERSION),
|
||||
"opencode" => Some(OPENCODE_NPM_VERSION),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_acp_launch_identity(
|
||||
backend: &AcpConversationData,
|
||||
configured_agent_id: &str,
|
||||
custom_command: &str,
|
||||
launch: &AcpLaunchConfig,
|
||||
) -> Result<(), String> {
|
||||
if backend.launch_fingerprint.is_empty() {
|
||||
return Err(
|
||||
"This ACP conversation predates Galaxy's agent-session safety metadata. Disable ACP and start a new conversation, then re-enable ACP and start a fresh ACP conversation."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
let current = effective_launch_fingerprint(
|
||||
configured_agent_id,
|
||||
preset_version(configured_agent_id, custom_command),
|
||||
launch,
|
||||
true,
|
||||
);
|
||||
if backend
|
||||
.agent_id
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(configured_agent_id.trim())
|
||||
&& backend.launch_fingerprint == current
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(
|
||||
"The ACP agent configuration has changed since this conversation was created, so Galaxy will not send its saved session ID to a different agent process. Start a new conversation to use the current ACP configuration."
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_acp_launch(
|
||||
agent_id: &str,
|
||||
custom_command: &str,
|
||||
custom_args: &[String],
|
||||
) -> Result<AcpLaunchConfig, String> {
|
||||
if !custom_command.trim().is_empty() {
|
||||
return AcpLaunchConfig::new(custom_command.trim())
|
||||
.args(custom_args.iter().cloned())
|
||||
.resolve_command();
|
||||
}
|
||||
|
||||
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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_acp_dispatch(
|
||||
feature_enabled: bool,
|
||||
setting_enabled: bool,
|
||||
is_remote: bool,
|
||||
) -> Result<(), String> {
|
||||
if !feature_enabled || !setting_enabled {
|
||||
return Err(
|
||||
"This conversation uses ACP, but Agent Client Protocol is currently disabled in Galaxy settings"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
if is_remote {
|
||||
return Err(
|
||||
"Galaxy blocked this ACP request because the terminal is remote (for example, through Wormhole or SSH). Local ACP agents could otherwise run native shell and file tools on the wrong host. Open a local terminal, disable ACP, and start a new provider-backed conversation; or start a new local ACP conversation."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "launch_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_builtin_agent_ids_are_rejected() {
|
||||
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
|
||||
|
||||
assert!(error.contains("Unknown ACP agent preset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agent_ids_are_allowed_with_an_explicit_executable() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let launch = resolve_acp_launch(
|
||||
"my-agent",
|
||||
executable.to_str().unwrap(),
|
||||
&["--acp".to_owned()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(launch.command, executable);
|
||||
assert_eq!(launch.args, vec!["--acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_model_identity_names_the_agent_instead_of_a_provider_model() {
|
||||
assert_eq!(acp_model_id(" Codex "), "acp:codex");
|
||||
assert_eq!(acp_model_id("My-Agent"), "acp:my-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_fingerprint_is_stable_and_sensitive_to_the_full_configuration() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let command = executable.to_str().unwrap();
|
||||
let launch = AcpLaunchConfig::new(&executable)
|
||||
.args(["serve"])
|
||||
.env("INITIAL_AGENT_MODE", "read-only")
|
||||
.preferred_auth_method("browser");
|
||||
let baseline = effective_launch_fingerprint("custom", None, &launch, true);
|
||||
|
||||
assert_eq!(
|
||||
baseline,
|
||||
effective_launch_fingerprint("CUSTOM", None, &launch, true)
|
||||
);
|
||||
assert_ne!(
|
||||
baseline,
|
||||
effective_launch_fingerprint("custom", None, &launch.clone().args(["other"]), true,)
|
||||
);
|
||||
assert_ne!(
|
||||
baseline,
|
||||
effective_launch_fingerprint(
|
||||
"custom",
|
||||
None,
|
||||
&launch.clone().env("INITIAL_AGENT_MODE", "workspace-write"),
|
||||
true,
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
baseline,
|
||||
effective_launch_fingerprint(
|
||||
"custom",
|
||||
None,
|
||||
&launch.clone().preferred_auth_method("api-key"),
|
||||
true,
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
acp_launch_fingerprint("custom", command, &["serve".to_owned()]),
|
||||
acp_launch_fingerprint("CUSTOM", command, &["serve".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_presets_ignore_custom_arguments_but_include_the_pinned_version() {
|
||||
assert_eq!(
|
||||
acp_launch_fingerprint("codex", "", &["ignored".to_owned()]),
|
||||
acp_launch_fingerprint("codex", "", &["also-ignored".to_owned()])
|
||||
);
|
||||
|
||||
let launch = AcpAgentPreset::Codex.launch_config();
|
||||
assert_ne!(
|
||||
effective_launch_fingerprint("codex", Some(CODEX_ACP_NPM_VERSION), &launch, false),
|
||||
effective_launch_fingerprint("codex", Some("different-version"), &launch, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_sessions_require_the_same_launch_identity() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let command = executable.to_str().unwrap();
|
||||
let args = vec!["serve".to_owned()];
|
||||
let launch = resolve_acp_launch("custom", command, &args).unwrap();
|
||||
let backend = AcpConversationData {
|
||||
agent_id: "custom".to_owned(),
|
||||
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
|
||||
session_id: Some("session-123".to_owned()),
|
||||
};
|
||||
|
||||
assert!(validate_acp_launch_identity(&backend, "custom", command, &launch).is_ok());
|
||||
assert!(
|
||||
validate_acp_launch_identity(&backend, "other", command, &launch)
|
||||
.unwrap_err()
|
||||
.contains("configuration has changed")
|
||||
);
|
||||
let different_launch = launch.clone().args(["other"]);
|
||||
assert!(
|
||||
validate_acp_launch_identity(&backend, "custom", command, &different_launch)
|
||||
.unwrap_err()
|
||||
.contains("configuration has changed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
|
||||
let backend = AcpConversationData {
|
||||
agent_id: "codex".to_owned(),
|
||||
launch_fingerprint: String::new(),
|
||||
session_id: Some("legacy-session".to_owned()),
|
||||
};
|
||||
|
||||
let launch = AcpAgentPreset::Codex.launch_config();
|
||||
assert!(validate_acp_launch_identity(&backend, "codex", "", &launch)
|
||||
.unwrap_err()
|
||||
.contains("predates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_acp_conversations_respect_the_runtime_toggle() {
|
||||
let error = validate_acp_dispatch(true, false, false).unwrap_err();
|
||||
|
||||
assert!(error.contains("currently disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_acp_processes_cannot_target_remote_terminals() {
|
||||
let error = validate_acp_dispatch(true, true, true).unwrap_err();
|
||||
|
||||
assert!(error.contains("Wormhole or SSH"));
|
||||
assert!(error.contains("start a new provider-backed conversation"));
|
||||
assert!(validate_acp_dispatch(true, true, false).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Agent Client Protocol integration for Galaxy's native agent conversation UI.
|
||||
//!
|
||||
//! ACP is a conversation backend, not an LLM provider. The external agent owns
|
||||
//! its model, authentication, prompt loop, and tool execution. Galaxy owns the
|
||||
//! process lifecycle, permissions, visible transcript, and local tool bridge.
|
||||
|
||||
mod launch;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod response_translator;
|
||||
mod runtime_model;
|
||||
mod transport;
|
||||
|
||||
pub(crate) use launch::{
|
||||
acp_launch_fingerprint, acp_model_id, resolve_acp_launch, validate_acp_dispatch,
|
||||
validate_acp_launch_identity,
|
||||
};
|
||||
pub(crate) use permissions::resolve_acp_permissions;
|
||||
pub(crate) use runtime_model::AcpRuntimeModel;
|
||||
pub(crate) use transport::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
|
||||
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
use galaxy_acp::AcpPermissionPolicy;
|
||||
|
||||
use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission, WriteToPtyPermission};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct AcpPermissionResolution {
|
||||
pub(crate) policy: AcpPermissionPolicy,
|
||||
pub(crate) auto_approve_protocol_requests: bool,
|
||||
pub(crate) expose_galaxy_tools: bool,
|
||||
pub(crate) allow_terminal_execute: bool,
|
||||
pub(crate) allow_terminal_interrupt: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_acp_permissions(profile: &AIExecutionProfile) -> AcpPermissionResolution {
|
||||
let mcp_allowed = profile.mcp_permissions == ActionPermission::AlwaysAllow;
|
||||
AcpPermissionResolution {
|
||||
// ACP permission requests expose only a broad category and display
|
||||
// text. They do not provide the structured command, path, or MCP server
|
||||
// identity required to enforce Galaxy's allowlists, denylists, and
|
||||
// protected-path rules. Keep every resource-affecting protocol
|
||||
// category denied until a request can be evaluated by those native
|
||||
// permission checks.
|
||||
policy: AcpPermissionPolicy::default(),
|
||||
// Run to Completion still honors command denylists and protected file
|
||||
// paths in Galaxy. It therefore cannot safely become ACP's blanket
|
||||
// `auto_approve`, which bypasses all category checks.
|
||||
auto_approve_protocol_requests: false,
|
||||
// The pane-pinned status tool is read-only and is useful even when the
|
||||
// profile does not permit MCP mutations. Individual mutation tools are
|
||||
// still omitted below unless every relevant permission is explicit.
|
||||
expose_galaxy_tools: true,
|
||||
allow_terminal_execute: mcp_allowed
|
||||
&& profile.execute_commands == ActionPermission::AlwaysAllow
|
||||
// The MCP subprocess cannot call `can_autoexecute_command` with
|
||||
// Galaxy's effective predicates. Until command authorization is
|
||||
// moved into the app-side handler, exposing arbitrary command
|
||||
// execution is safe only when there is no denylist to bypass.
|
||||
&& profile.command_denylist.is_empty(),
|
||||
allow_terminal_interrupt: mcp_allowed
|
||||
&& profile.write_to_pty == WriteToPtyPermission::AlwaysAllow,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "permissions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permissive_profile_only_enables_exact_galaxy_tools() {
|
||||
let profile = AIExecutionProfile {
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAsk,
|
||||
command_denylist: Vec::new(),
|
||||
..AIExecutionProfile::default()
|
||||
};
|
||||
|
||||
let permissions = resolve_acp_permissions(&profile);
|
||||
|
||||
assert_eq!(permissions.policy, AcpPermissionPolicy::default());
|
||||
assert!(!permissions.auto_approve_protocol_requests);
|
||||
assert!(permissions.expose_galaxy_tools);
|
||||
assert!(permissions.allow_terminal_execute);
|
||||
assert!(!permissions.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_decides_and_always_ask_do_not_bypass_galaxy_approval() {
|
||||
let profile = AIExecutionProfile {
|
||||
web_search_enabled: false,
|
||||
..AIExecutionProfile::default()
|
||||
};
|
||||
|
||||
let permissions = resolve_acp_permissions(&profile);
|
||||
|
||||
assert_eq!(permissions.policy, AcpPermissionPolicy::default());
|
||||
assert!(!permissions.auto_approve_protocol_requests);
|
||||
assert!(permissions.expose_galaxy_tools);
|
||||
assert!(!permissions.allow_terminal_execute);
|
||||
assert!(!permissions.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_to_completion_cannot_bypass_profile_gates_or_protocol_checks() {
|
||||
// ResponseStream deliberately uses this same resolution in Run to
|
||||
// Completion mode. RTC may skip individual confirmations, but Galaxy's
|
||||
// native execution path still enforces command denylists and protected
|
||||
// paths, which ACP's unstructured permission request cannot evaluate.
|
||||
let permissions = resolve_acp_permissions(&AIExecutionProfile::default());
|
||||
|
||||
assert_eq!(permissions.policy, AcpPermissionPolicy::default());
|
||||
assert!(!permissions.auto_approve_protocol_requests);
|
||||
assert!(permissions.expose_galaxy_tools);
|
||||
assert!(!permissions.allow_terminal_execute);
|
||||
assert!(!permissions.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_mutation_tools_require_both_profile_permissions() {
|
||||
let mut profile = AIExecutionProfile {
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
command_denylist: Vec::new(),
|
||||
..AIExecutionProfile::default()
|
||||
};
|
||||
|
||||
let without_mcp = resolve_acp_permissions(&profile);
|
||||
assert!(!without_mcp.allow_terminal_execute);
|
||||
assert!(!without_mcp.allow_terminal_interrupt);
|
||||
|
||||
profile.mcp_permissions = ActionPermission::AlwaysAllow;
|
||||
let with_mcp = resolve_acp_permissions(&profile);
|
||||
assert!(with_mcp.allow_terminal_execute);
|
||||
assert!(with_mcp.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_execute_is_hidden_when_any_command_denylist_is_effective() {
|
||||
let mut profile = AIExecutionProfile::default();
|
||||
assert!(!profile.command_denylist.is_empty());
|
||||
profile.execute_commands = ActionPermission::AlwaysAllow;
|
||||
profile.mcp_permissions = ActionPermission::AlwaysAllow;
|
||||
|
||||
let permissions = resolve_acp_permissions(&profile);
|
||||
|
||||
assert!(!permissions.allow_terminal_execute);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use galaxy_acp::{ContentBlock, ImageContent, TextContent};
|
||||
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
|
||||
|
||||
const CONTEXT_HEADER: &str = "\n\n<galaxy_context hidden_from_transcript=\"true\">\n";
|
||||
const CONTEXT_FOOTER: &str = "\n</galaxy_context>";
|
||||
const SYSTEM_REQUEST_PLACEHOLDER: &str =
|
||||
"Handle the Galaxy system request in the hidden context below.";
|
||||
const MAX_RUNNING_COMMAND_OUTPUT_CHARS: usize = 32_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(super) struct GalaxyTerminalTools {
|
||||
pub(super) status: bool,
|
||||
pub(super) interrupt: bool,
|
||||
}
|
||||
|
||||
/// Converts Galaxy's rich request input into ACP prompt content.
|
||||
///
|
||||
/// The first text block contains the user's visible request. Context and global
|
||||
/// rules are appended in a clearly delimited block that is sent to the agent but
|
||||
/// is not copied into Galaxy's visible user bubble. Images stay as native ACP
|
||||
/// image blocks so adapters can forward them to multimodal models.
|
||||
pub(super) fn prompt_content(
|
||||
params: &RequestParams,
|
||||
terminal_tools: GalaxyTerminalTools,
|
||||
) -> Result<Vec<ContentBlock>, String> {
|
||||
let visible_query = params
|
||||
.input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(AIAgentInput::display_query);
|
||||
|
||||
let mut hidden_context = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
|
||||
for input in ¶ms.input {
|
||||
append_hidden_input(input, terminal_tools, &mut hidden_context)?;
|
||||
|
||||
if let Some(context) = input.context() {
|
||||
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}")),
|
||||
));
|
||||
}
|
||||
AIAgentContext::SelectedText(text) => {
|
||||
hidden_context.push(format!("Selected text:\n{text}"));
|
||||
}
|
||||
AIAgentContext::File(file) => {
|
||||
hidden_context.push(format!("File context:\n{}", serialize_context(file)?));
|
||||
}
|
||||
AIAgentContext::Directory { pwd, .. } => {
|
||||
if let Some(pwd) = pwd {
|
||||
hidden_context.push(format!("Working directory: {pwd}"));
|
||||
}
|
||||
}
|
||||
AIAgentContext::ExecutionEnvironment(environment) => {
|
||||
hidden_context.push(format!(
|
||||
"Execution environment:\n{}",
|
||||
serialize_context(environment)?
|
||||
))
|
||||
}
|
||||
AIAgentContext::CurrentTime { current_time } => {
|
||||
hidden_context.push(format!("Current time: {current_time}"));
|
||||
}
|
||||
AIAgentContext::Codebase { path, name } => {
|
||||
hidden_context.push(format!("Codebase: {name} ({path})"));
|
||||
}
|
||||
AIAgentContext::ProjectRules { .. }
|
||||
| AIAgentContext::Git { .. }
|
||||
| AIAgentContext::Repository { .. }
|
||||
| AIAgentContext::PullRequest { .. }
|
||||
| AIAgentContext::Skills { .. }
|
||||
| AIAgentContext::Block(_) => {
|
||||
hidden_context.push(format!(
|
||||
"Additional Galaxy context:\n{}",
|
||||
serialize_context(item)?
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let AIAgentInput::UserQuery {
|
||||
referenced_attachments,
|
||||
..
|
||||
} = input
|
||||
{
|
||||
for (name, attachment) in referenced_attachments {
|
||||
hidden_context.push(attachment_text(name, attachment)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !params.global_rules.is_empty() {
|
||||
let rules = params
|
||||
.global_rules
|
||||
.iter()
|
||||
.map(|(name, content)| format!("Rule: {name}\n{content}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
hidden_context.push(format!("Galaxy rules:\n{rules}"));
|
||||
}
|
||||
|
||||
let mut text = visible_query.unwrap_or_else(|| SYSTEM_REQUEST_PLACEHOLDER.to_owned());
|
||||
if !hidden_context.is_empty() {
|
||||
text.push_str(CONTEXT_HEADER);
|
||||
text.push_str(&hidden_context.join("\n\n"));
|
||||
text.push_str(CONTEXT_FOOTER);
|
||||
}
|
||||
params.redact_text_for_model(&mut text);
|
||||
|
||||
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
|
||||
prompt.extend(images);
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
fn append_hidden_input(
|
||||
input: &AIAgentInput,
|
||||
terminal_tools: GalaxyTerminalTools,
|
||||
hidden_context: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
match input {
|
||||
AIAgentInput::UserQuery {
|
||||
running_command: Some(command),
|
||||
..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"A command is running in the user's visible Galaxy terminal.\n\
|
||||
Command: {}\n\
|
||||
Galaxy block_id: {}\n\
|
||||
Alternate screen: {}\n\
|
||||
Current output:\n{}\n\n{}",
|
||||
command.command,
|
||||
command.block_id,
|
||||
command.is_alt_screen_active,
|
||||
tail_chars(&command.grid_contents, MAX_RUNNING_COMMAND_OUTPUT_CHARS),
|
||||
running_command_tool_guidance(terminal_tools),
|
||||
));
|
||||
}
|
||||
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
|
||||
AIAgentInput::AutoCodeDiffQuery { query, .. } => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: create a code diff.\n{query}"
|
||||
));
|
||||
}
|
||||
AIAgentInput::ResumeConversation { .. } => {
|
||||
hidden_context.push(
|
||||
"Galaxy system request: resume the current conversation and continue the task."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
AIAgentInput::InitProjectRules { .. } => {
|
||||
hidden_context.push(
|
||||
"Galaxy system request: initialize appropriate project rules for this workspace."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
AIAgentInput::CreateEnvironment { repo_paths, .. } => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: create a development environment for these repositories:\n{}",
|
||||
repo_paths.join("\n")
|
||||
));
|
||||
}
|
||||
AIAgentInput::TriggerPassiveSuggestion {
|
||||
attachments,
|
||||
trigger,
|
||||
..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy background request: generate a concise useful suggestion for this event:\n{trigger:#?}"
|
||||
));
|
||||
for (index, attachment) in attachments.iter().enumerate() {
|
||||
hidden_context.push(attachment_text(
|
||||
&format!("background attachment {}", index + 1),
|
||||
attachment,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
AIAgentInput::CloneRepository { .. } => {
|
||||
// The display query already contains the requested repository URL.
|
||||
}
|
||||
AIAgentInput::CodeReview {
|
||||
review_comments, ..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: address this code-review batch:\n{review_comments:#?}"
|
||||
));
|
||||
}
|
||||
AIAgentInput::FetchReviewComments { repo_path, .. } => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: fetch and address review comments for {repo_path}."
|
||||
));
|
||||
}
|
||||
AIAgentInput::SummarizeConversation { prompt, .. } => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: summarize the conversation for future continuation.{}",
|
||||
prompt
|
||||
.as_deref()
|
||||
.map(|prompt| format!("\nAdditional instructions: {prompt}"))
|
||||
.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
AIAgentInput::InvokeSkill {
|
||||
skill, user_query, ..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy skill instructions for {}:\n{}",
|
||||
skill.name, skill.content
|
||||
));
|
||||
if let Some(user_query) = user_query {
|
||||
for (name, attachment) in &user_query.referenced_attachments {
|
||||
hidden_context.push(attachment_text(name, attachment)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
AIAgentInput::StartFromAmbientRunPrompt {
|
||||
ambient_run_id,
|
||||
runtime_skill,
|
||||
attachments_dir,
|
||||
..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy system request: continue ambient run {ambient_run_id}.{}{}",
|
||||
runtime_skill
|
||||
.as_ref()
|
||||
.map(|skill| format!("\nRuntime skill:\n{}", skill.content))
|
||||
.unwrap_or_default(),
|
||||
attachments_dir
|
||||
.as_deref()
|
||||
.map(|path| format!("\nDownloaded attachment directory: {path}"))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
AIAgentInput::ActionResult { result, .. } => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy tool result for {}:\n{}",
|
||||
result.id,
|
||||
MarkdownActionResult(&result.result)
|
||||
));
|
||||
}
|
||||
AIAgentInput::MessagesReceivedFromAgents { messages } => {
|
||||
hidden_context.push(format!(
|
||||
"Messages received from other Galaxy agents:\n{messages:#?}"
|
||||
));
|
||||
}
|
||||
AIAgentInput::EventsFromAgents { events } => {
|
||||
hidden_context.push(format!(
|
||||
"Events received from other Galaxy agents:\n{events:#?}"
|
||||
));
|
||||
}
|
||||
AIAgentInput::PassiveSuggestionResult {
|
||||
trigger,
|
||||
suggestion,
|
||||
..
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy passive-suggestion feedback.\nTrigger: {trigger:#?}\nResult: {suggestion:#?}"
|
||||
));
|
||||
}
|
||||
AIAgentInput::OrchestrationConfigUpdate {
|
||||
plan_id,
|
||||
config,
|
||||
status,
|
||||
} => {
|
||||
hidden_context.push(format!(
|
||||
"Galaxy orchestration configuration changed for plan {plan_id}.\n\
|
||||
Status: {status:#?}\nConfiguration: {config:#?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn running_command_tool_guidance(terminal_tools: GalaxyTerminalTools) -> &'static str {
|
||||
match (terminal_tools.status, terminal_tools.interrupt) {
|
||||
(true, true) => {
|
||||
"Use `galaxy_terminal_status` to inspect this exact pane. For a deadline, call \
|
||||
`galaxy_terminal_interrupt_at` once with this exact block_id and the target \
|
||||
`running_for_ms`; Galaxy performs the wait outside the model loop and refuses to \
|
||||
interrupt a replacement block. Use `galaxy_terminal_interrupt` only when an \
|
||||
immediate stop is requested."
|
||||
}
|
||||
(true, false) => {
|
||||
"The read-only `galaxy_terminal_status` tool can inspect this exact pane, but no \
|
||||
Galaxy terminal mutation tool is available under the active execution profile. Do \
|
||||
not claim that you can stop this command or enforce a deadline."
|
||||
}
|
||||
(false, false) => {
|
||||
"Galaxy terminal control tools are unavailable for this turn. Do not claim that you \
|
||||
can monitor, stop, or enforce a deadline on this existing command."
|
||||
}
|
||||
(false, true) => {
|
||||
"Galaxy exposed an inconsistent terminal tool configuration. Do not attempt to \
|
||||
monitor or interrupt this existing command."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_chars(text: &str, max_chars: usize) -> &str {
|
||||
let Some((start, _)) = text.char_indices().rev().nth(max_chars.saturating_sub(1)) else {
|
||||
return text;
|
||||
};
|
||||
&text[start..]
|
||||
}
|
||||
|
||||
fn attachment_text(name: &str, attachment: &AIAgentAttachment) -> Result<String, String> {
|
||||
let content = match attachment {
|
||||
AIAgentAttachment::PlainText(text) => text.clone(),
|
||||
AIAgentAttachment::DocumentContent { content, .. } => content.clone(),
|
||||
AIAgentAttachment::DiffHunk { diff_content, .. } => diff_content.clone(),
|
||||
AIAgentAttachment::FilePathReference { file_path, .. } => {
|
||||
format!("Local file reference: {file_path}")
|
||||
}
|
||||
AIAgentAttachment::DriveObject { .. }
|
||||
| AIAgentAttachment::DiffSet { .. }
|
||||
| AIAgentAttachment::Block(_) => serialize_context(attachment)?,
|
||||
};
|
||||
Ok(format!("Attachment {name}:\n{content}"))
|
||||
}
|
||||
|
||||
fn serialize_context(value: &impl serde::Serialize) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(value)
|
||||
.map_err(|error| format!("failed to serialize ACP prompt context: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prompt_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_acp::ContentBlock;
|
||||
use regex::Regex;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::{prompt_content, GalaxyTerminalTools};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAttachment, AIAgentContext, AIAgentInput, ImageContext, RunningCommand, UserQueryMode,
|
||||
};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::secrets;
|
||||
|
||||
struct SecretRegexReset;
|
||||
|
||||
impl Drop for SecretRegexReset {
|
||||
fn drop(&mut self) {
|
||||
secrets::set_user_and_enterprise_secret_regexes(
|
||||
std::iter::empty::<&Regex>(),
|
||||
std::iter::empty::<&Regex>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn install_test_secret_regex(pattern: &str) -> SecretRegexReset {
|
||||
let regex = Regex::new(pattern).expect("valid test secret regex");
|
||||
secrets::set_user_and_enterprise_secret_regexes([®ex], std::iter::empty::<&Regex>());
|
||||
SecretRegexReset
|
||||
}
|
||||
|
||||
fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
|
||||
AIAgentInput::UserQuery {
|
||||
query: query.to_owned(),
|
||||
context: Arc::from(context),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: None,
|
||||
intended_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_images_as_native_acp_content() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query(
|
||||
"What is in this image?",
|
||||
vec![AIAgentContext::Image(ImageContext {
|
||||
data: "aW1hZ2U=".to_owned(),
|
||||
mime_type: "image/png".to_owned(),
|
||||
file_name: "screen.png".to_owned(),
|
||||
is_figma: false,
|
||||
})],
|
||||
)];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
assert_eq!(prompt.len(), 2);
|
||||
assert!(matches!(
|
||||
&prompt[0],
|
||||
ContentBlock::Text(text) if text.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")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_rules_and_selected_text_without_changing_visible_query() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query(
|
||||
"Fix this",
|
||||
vec![AIAgentContext::SelectedText("broken()".to_owned())],
|
||||
)];
|
||||
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."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![AIAgentInput::AutoCodeDiffQuery {
|
||||
query: "Repair the failing unit test.".to_owned(),
|
||||
context: Arc::from([]),
|
||||
}];
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_command_identity_and_output_are_sent_as_hidden_context() {
|
||||
let block_id = BlockId::from("session-42".to_owned());
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![AIAgentInput::UserQuery {
|
||||
query: "Stop this after 75 seconds.".to_owned(),
|
||||
context: Arc::from([]),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: Some(RunningCommand {
|
||||
command: "script/run-soak-test".to_owned(),
|
||||
block_id: block_id.clone(),
|
||||
grid_contents: "elapsed: 41s".to_owned(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
}),
|
||||
intended_agent: None,
|
||||
}];
|
||||
|
||||
let prompt = prompt_content(
|
||||
¶ms,
|
||||
GalaxyTerminalTools {
|
||||
status: true,
|
||||
interrupt: true,
|
||||
},
|
||||
)
|
||||
.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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_command_prompt_does_not_advertise_unavailable_mutations() {
|
||||
let block_id = BlockId::from("session-42".to_owned());
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![AIAgentInput::UserQuery {
|
||||
query: "Stop this after 75 seconds.".to_owned(),
|
||||
context: Arc::from([]),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: Some(RunningCommand {
|
||||
command: "script/run-soak-test".to_owned(),
|
||||
block_id,
|
||||
grid_contents: String::new(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
}),
|
||||
intended_agent: None,
|
||||
}];
|
||||
|
||||
let prompt = prompt_content(
|
||||
¶ms,
|
||||
GalaxyTerminalTools {
|
||||
status: true,
|
||||
interrupt: false,
|
||||
},
|
||||
)
|
||||
.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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn redacts_request_text_before_creating_acp_content_blocks() {
|
||||
const SECRET: &str = "GALAXY_TEST_SECRET";
|
||||
let _secret_regex_reset = install_test_secret_regex(SECRET);
|
||||
let block_id = BlockId::from("session-redaction".to_owned());
|
||||
let mut referenced_attachments = HashMap::new();
|
||||
referenced_attachments.insert(
|
||||
"notes.txt".to_owned(),
|
||||
AIAgentAttachment::PlainText(format!("attachment {SECRET}")),
|
||||
);
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.enable_secret_redaction_for_test();
|
||||
params.global_rules = vec![("Private rule".to_owned(), format!("Never print {SECRET}."))];
|
||||
params.input = vec![AIAgentInput::UserQuery {
|
||||
query: format!("inspect {SECRET}"),
|
||||
context: Arc::from([
|
||||
AIAgentContext::SelectedText(format!("selected {SECRET}")),
|
||||
AIAgentContext::Image(ImageContext {
|
||||
data: "aW1hZ2U=".to_owned(),
|
||||
mime_type: "image/png".to_owned(),
|
||||
file_name: format!("{SECRET}.png"),
|
||||
is_figma: false,
|
||||
}),
|
||||
]),
|
||||
static_query_type: None,
|
||||
referenced_attachments,
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: Some(RunningCommand {
|
||||
command: format!("echo {SECRET}"),
|
||||
block_id,
|
||||
grid_contents: format!("output {SECRET}"),
|
||||
cursor: format!("cursor {SECRET}"),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
}),
|
||||
intended_agent: None,
|
||||
}];
|
||||
|
||||
let prompt = prompt_content(¶ms, GalaxyTerminalTools::default()).expect("prompt");
|
||||
let ContentBlock::Text(text) = &prompt[0] else {
|
||||
panic!("expected text");
|
||||
};
|
||||
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:"));
|
||||
assert!(matches!(
|
||||
&prompt[1],
|
||||
ContentBlock::Image(image)
|
||||
if image.data == "aW1hZ2U="
|
||||
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
|
||||
));
|
||||
|
||||
// Prompt redaction must not mutate the local transcript copy.
|
||||
assert_eq!(
|
||||
params.input[0].display_query().as_deref(),
|
||||
Some("inspect GALAXY_TEST_SECRET")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
@@ -0,0 +1,351 @@
|
||||
use galaxy_acp::{
|
||||
AcpEvent, 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"),
|
||||
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"),
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use galaxy_acp::{AcpLaunchConfig, AcpManagerConfig, AcpSessionManager};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Long-lived ACP process/session owner shared by all agent response streams.
|
||||
pub(crate) struct AcpRuntimeModel {
|
||||
managed: Option<ManagedRuntime>,
|
||||
}
|
||||
|
||||
struct ManagedRuntime {
|
||||
launch: AcpLaunchConfig,
|
||||
manager: AcpSessionManager,
|
||||
}
|
||||
|
||||
impl AcpRuntimeModel {
|
||||
pub(crate) fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self { managed: None }
|
||||
}
|
||||
|
||||
pub(crate) fn manager(
|
||||
&mut self,
|
||||
config: AcpManagerConfig,
|
||||
) -> Result<AcpSessionManager, String> {
|
||||
if let Some(managed) = &self.managed {
|
||||
if managed.launch == config.launch && managed.manager.is_alive() {
|
||||
return Ok(managed.manager.clone());
|
||||
}
|
||||
}
|
||||
let launch = config.launch.clone();
|
||||
let manager = AcpSessionManager::spawn(config).map_err(|error| error.to_string())?;
|
||||
self.managed = Some(ManagedRuntime {
|
||||
launch,
|
||||
manager: manager.clone(),
|
||||
});
|
||||
Ok(manager)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AcpRuntimeModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AcpRuntimeModel {}
|
||||
@@ -0,0 +1,354 @@
|
||||
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, SessionId,
|
||||
TextContent,
|
||||
};
|
||||
|
||||
use super::launch::acp_model_id;
|
||||
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::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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AcpSteeringRequest {
|
||||
display_text: String,
|
||||
model_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GalaxyMcpTarget {
|
||||
pub(crate) window_id: String,
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) pane_id: String,
|
||||
}
|
||||
|
||||
impl AcpSteeringRequest {
|
||||
pub(crate) fn text(display_text: String, model_text: String) -> Self {
|
||||
Self {
|
||||
display_text,
|
||||
model_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Self { slot }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpSessionHandleGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active_session) = self.slot.lock() {
|
||||
*active_session = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn acp_output_stream(
|
||||
manager: AcpSessionManager,
|
||||
params: RequestParams,
|
||||
conversation_id: String,
|
||||
backend: AcpConversationData,
|
||||
galaxy_mcp_server: Option<McpServer>,
|
||||
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>,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> api::ResponseStream {
|
||||
let mut translator = response_translator(¶ms, &backend);
|
||||
let terminal_tools = GalaxyTerminalTools {
|
||||
status: galaxy_mcp_server.is_some(),
|
||||
interrupt: galaxy_mcp_server.is_some() && galaxy_terminal_interrupt_available,
|
||||
};
|
||||
let prompt = match prompt_content(¶ms, terminal_tools) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(error) => return translated_startup_error_stream(translator, &error),
|
||||
};
|
||||
let cwd = params
|
||||
.session_context
|
||||
.current_working_directory()
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let mut mcp_servers = Vec::new();
|
||||
if let Some(server) = galaxy_mcp_server {
|
||||
mcp_servers.push(server);
|
||||
}
|
||||
let request = AcpTurnRequest {
|
||||
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,
|
||||
};
|
||||
let (session, events) = match manager.run_turn(request) {
|
||||
Ok(turn) => turn,
|
||||
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
|
||||
};
|
||||
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone());
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let _session_handle_guard = session_handle_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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
event = events.next() => {
|
||||
let Some(event) = event else {
|
||||
if steering_result.is_terminated() && steering_queue.is_empty() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
match translator.translate(event) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Box::pin(stream)
|
||||
}
|
||||
|
||||
pub(crate) fn acp_startup_error_stream(
|
||||
params: &RequestParams,
|
||||
backend: &AcpConversationData,
|
||||
message: &str,
|
||||
) -> api::ResponseStream {
|
||||
translated_startup_error_stream(response_translator(params, backend), message)
|
||||
}
|
||||
|
||||
fn response_translator(
|
||||
params: &RequestParams,
|
||||
backend: &AcpConversationData,
|
||||
) -> AcpResponseTranslator {
|
||||
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(
|
||||
task_id,
|
||||
params.tasks.is_empty(),
|
||||
user_query,
|
||||
acp_model_id(&backend.agent_id),
|
||||
)
|
||||
}
|
||||
|
||||
fn request_user_query(params: &RequestParams) -> Option<String> {
|
||||
let should_display = params.metadata.as_ref().is_none_or(|metadata| {
|
||||
!metadata.is_auto_resume_after_error
|
||||
&& matches!(
|
||||
metadata.entrypoint,
|
||||
EntrypointType::PromptSuggestion { .. }
|
||||
| EntrypointType::ZeroStateAgentModePromptSuggestion
|
||||
| EntrypointType::UserInitiated
|
||||
| EntrypointType::SharedSession
|
||||
| EntrypointType::CloneRepository
|
||||
)
|
||||
});
|
||||
if !should_display {
|
||||
return None;
|
||||
}
|
||||
params
|
||||
.input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(crate::ai::agent::AIAgentInput::display_query)
|
||||
}
|
||||
|
||||
pub(crate) fn galaxy_mcp_server(
|
||||
target: &GalaxyMcpTarget,
|
||||
allow_terminal_execute: bool,
|
||||
allow_terminal_interrupt: bool,
|
||||
) -> Result<McpServer, String> {
|
||||
if !cfg!(unix) {
|
||||
return Err("ACP Galaxy terminal tools are currently available only on Unix".to_owned());
|
||||
}
|
||||
let executable = std::env::current_exe()
|
||||
.map_err(|error| format!("failed to locate the Galaxy executable: {error}"))?;
|
||||
let args = galaxy_mcp_args(target, allow_terminal_execute, allow_terminal_interrupt);
|
||||
Ok(McpServer::Stdio(
|
||||
McpServerStdio::new("Galaxy", executable).args(args),
|
||||
))
|
||||
}
|
||||
|
||||
fn galaxy_mcp_args(
|
||||
target: &GalaxyMcpTarget,
|
||||
allow_terminal_execute: bool,
|
||||
allow_terminal_interrupt: bool,
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--galaxyctrl".to_owned(),
|
||||
"mcp".to_owned(),
|
||||
"--pid".to_owned(),
|
||||
std::process::id().to_string(),
|
||||
"--window".to_owned(),
|
||||
target.window_id.clone(),
|
||||
"--tab".to_owned(),
|
||||
target.tab_id.clone(),
|
||||
"--pane".to_owned(),
|
||||
target.pane_id.clone(),
|
||||
"--agent-safe".to_owned(),
|
||||
];
|
||||
if allow_terminal_execute {
|
||||
args.push("--allow-terminal-execute".to_owned());
|
||||
}
|
||||
if allow_terminal_interrupt {
|
||||
args.push("--allow-terminal-interrupt".to_owned());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn translated_startup_error_stream(
|
||||
mut translator: AcpResponseTranslator,
|
||||
message: &str,
|
||||
) -> api::ResponseStream {
|
||||
let events = translator.startup_error(message);
|
||||
Box::pin(futures::stream::iter(events.into_iter().map(Ok)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transport_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{galaxy_mcp_args, request_user_query, GalaxyMcpTarget};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentInput, EntrypointType, RequestMetadata, UserQueryMode};
|
||||
|
||||
#[test]
|
||||
fn galaxy_mcp_is_pinned_to_the_app_process_and_full_terminal_hierarchy() {
|
||||
let target = GalaxyMcpTarget {
|
||||
window_id: "WindowId(7)".to_owned(),
|
||||
tab_id: "EntityId(21)".to_owned(),
|
||||
pane_id: "Pane Terminal (42)".to_owned(),
|
||||
};
|
||||
let args = galaxy_mcp_args(&target, true, true);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--galaxyctrl",
|
||||
"mcp",
|
||||
"--pid",
|
||||
&std::process::id().to_string(),
|
||||
"--window",
|
||||
"WindowId(7)",
|
||||
"--tab",
|
||||
"EntityId(21)",
|
||||
"--pane",
|
||||
"Pane Terminal (42)",
|
||||
"--agent-safe",
|
||||
"--allow-terminal-execute",
|
||||
"--allow-terminal-interrupt",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galaxy_mcp_omits_terminal_mutations_without_profile_permission() {
|
||||
let target = GalaxyMcpTarget {
|
||||
window_id: "WindowId(7)".to_owned(),
|
||||
tab_id: "EntityId(21)".to_owned(),
|
||||
pane_id: "Pane Terminal (42)".to_owned(),
|
||||
};
|
||||
let args = galaxy_mcp_args(&target, false, false);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--galaxyctrl",
|
||||
"mcp",
|
||||
"--pid",
|
||||
&std::process::id().to_string(),
|
||||
"--window",
|
||||
"WindowId(7)",
|
||||
"--tab",
|
||||
"EntityId(21)",
|
||||
"--pane",
|
||||
"Pane Terminal (42)",
|
||||
"--agent-safe",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn request_with_entrypoint(entrypoint: EntrypointType) -> RequestParams {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input.push(AIAgentInput::UserQuery {
|
||||
query: "visible request".to_owned(),
|
||||
context: Arc::from([]),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: None,
|
||||
intended_agent: None,
|
||||
});
|
||||
params.metadata = Some(RequestMetadata {
|
||||
is_autodetected_user_query: false,
|
||||
entrypoint,
|
||||
is_auto_resume_after_error: false,
|
||||
});
|
||||
params
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_user_entrypoints_keep_the_initial_query_bubble() {
|
||||
let params = request_with_entrypoint(EntrypointType::UserInitiated);
|
||||
|
||||
assert_eq!(
|
||||
request_user_query(¶ms).as_deref(),
|
||||
Some("visible request")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_and_system_entrypoints_hide_the_synthetic_query_bubble() {
|
||||
for entrypoint in [
|
||||
EntrypointType::InitProjectRules,
|
||||
EntrypointType::TriggerPassiveSuggestion { trigger: None },
|
||||
EntrypointType::AgentInitiated,
|
||||
EntrypointType::ResumeConversation,
|
||||
] {
|
||||
assert_eq!(
|
||||
request_user_query(&request_with_entrypoint(entrypoint)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_error_resume_does_not_duplicate_the_original_user_query() {
|
||||
let mut params = request_with_entrypoint(EntrypointType::UserInitiated);
|
||||
params.metadata.as_mut().unwrap().is_auto_resume_after_error = true;
|
||||
|
||||
assert_eq!(request_user_query(¶ms), None);
|
||||
}
|
||||
@@ -92,6 +92,9 @@ impl TryFrom<ServerConversationToken>
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestParams {
|
||||
/// Galaxy terminal view that originated the request. This is retained
|
||||
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
|
||||
pub terminal_view_id: Option<EntityId>,
|
||||
pub input: Vec<AIAgentInput>,
|
||||
pub conversation_token: Option<ServerConversationToken>,
|
||||
pub forked_from_conversation_token: Option<ServerConversationToken>,
|
||||
@@ -182,6 +185,7 @@ impl RequestParams {
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test() -> Self {
|
||||
Self {
|
||||
terminal_view_id: None,
|
||||
input: vec![],
|
||||
conversation_token: None,
|
||||
forked_from_conversation_token: None,
|
||||
@@ -222,6 +226,19 @@ impl RequestParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the request's configured secret-redaction policy to text immediately
|
||||
/// before it is handed to an external model or agent.
|
||||
pub(crate) fn redact_text_for_model(&self, text: &mut String) {
|
||||
if self.should_redact_secrets {
|
||||
super::redaction::redact_secrets(text);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn enable_secret_redaction_for_test(&mut self) {
|
||||
self.should_redact_secrets = true;
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
terminal_view_id: Option<EntityId>,
|
||||
session_context: SessionContext,
|
||||
@@ -372,6 +389,7 @@ impl RequestParams {
|
||||
.context_window_limit_for_request(app);
|
||||
|
||||
Self {
|
||||
terminal_view_id,
|
||||
input: request_input.all_inputs().cloned().collect(),
|
||||
conversation_token: conversation.server_conversation_token,
|
||||
forked_from_conversation_token: conversation.forked_from_conversation_token,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
||||
|
||||
let agent_conversation_data = match restoration_mode {
|
||||
RestorationMode::Fork => AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: usage_metadata,
|
||||
reverted_action_ids: None,
|
||||
@@ -93,6 +94,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
||||
messages_summarized_up_to: 0,
|
||||
},
|
||||
RestorationMode::Continue => AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(
|
||||
metadata.server_conversation_token.as_str().to_string(),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
|
||||
let model = LLMId::from("test-model");
|
||||
|
||||
RequestParams {
|
||||
terminal_view_id: None,
|
||||
input: vec![],
|
||||
conversation_token: None,
|
||||
forked_from_conversation_token: None,
|
||||
|
||||
@@ -62,8 +62,8 @@ use crate::ai::skills::SkillDescriptor;
|
||||
use crate::code_review::CodeReviewTelemetryEvent;
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::persistence::model::{
|
||||
AgentConversationData, ContextWindowSegment, ConversationUsageMetadata, ModelTokenUsage,
|
||||
PersistedAutoexecuteMode, ToolUsageMetadata,
|
||||
AcpConversationData, AgentBackend, AgentConversationData, ContextWindowSegment,
|
||||
ConversationUsageMetadata, ModelTokenUsage, PersistedAutoexecuteMode, ToolUsageMetadata,
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::ServerId;
|
||||
@@ -226,6 +226,9 @@ pub struct AIConversation {
|
||||
/// credits spent, token usage, and tool usage.
|
||||
conversation_usage_metadata: ConversationUsageMetadata,
|
||||
|
||||
/// Runtime responsible for executing this conversation.
|
||||
agent_backend: AgentBackend,
|
||||
|
||||
/// The server-generated unique "token" for this conversation.
|
||||
///
|
||||
/// This must be roundtripped to the server when sending follow-ups within a given conversation.
|
||||
@@ -351,6 +354,18 @@ pub(crate) fn artifact_from_fork_proto(
|
||||
|
||||
impl AIConversation {
|
||||
pub fn new(is_viewing_shared_session: bool, is_cli_agent_transcript: bool) -> Self {
|
||||
Self::new_with_agent_backend(
|
||||
is_viewing_shared_session,
|
||||
is_cli_agent_transcript,
|
||||
AgentBackend::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_agent_backend(
|
||||
is_viewing_shared_session: bool,
|
||||
is_cli_agent_transcript: bool,
|
||||
agent_backend: AgentBackend,
|
||||
) -> Self {
|
||||
let root_task = Task::new_optimistic_root();
|
||||
Self {
|
||||
id: AIConversationId::new(),
|
||||
@@ -364,6 +379,7 @@ impl AIConversation {
|
||||
status_error: None,
|
||||
has_opened_code_review: false,
|
||||
conversation_usage_metadata: ConversationUsageMetadata::default(),
|
||||
agent_backend,
|
||||
server_conversation_token: None,
|
||||
task_id: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
@@ -522,6 +538,7 @@ impl AIConversation {
|
||||
};
|
||||
|
||||
let (
|
||||
agent_backend,
|
||||
server_conversation_token,
|
||||
forked_from_server_conversation_token,
|
||||
conversation_usage_metadata,
|
||||
@@ -571,6 +588,7 @@ impl AIConversation {
|
||||
AIConversationAutoexecuteMode::default()
|
||||
};
|
||||
(
|
||||
data.agent_backend,
|
||||
server_conversation_token,
|
||||
forked_from_server_conversation_token,
|
||||
conversation_usage_metadata,
|
||||
@@ -590,6 +608,7 @@ impl AIConversation {
|
||||
)
|
||||
} else {
|
||||
(
|
||||
AgentBackend::default(),
|
||||
None,
|
||||
None,
|
||||
ConversationUsageMetadata::default(),
|
||||
@@ -643,6 +662,7 @@ impl AIConversation {
|
||||
code_review: None,
|
||||
has_opened_code_review: false,
|
||||
conversation_usage_metadata,
|
||||
agent_backend,
|
||||
server_conversation_token,
|
||||
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
|
||||
forked_from_server_conversation_token,
|
||||
@@ -681,6 +701,25 @@ impl AIConversation {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn agent_backend(&self) -> &AgentBackend {
|
||||
&self.agent_backend
|
||||
}
|
||||
|
||||
/// Records a resumable ACP session ID.
|
||||
///
|
||||
/// Returns `false` when called for a native provider conversation.
|
||||
pub fn set_acp_session_id(&mut self, session_id: impl Into<String>) -> bool {
|
||||
let AgentBackend::Acp(AcpConversationData {
|
||||
session_id: current_session_id,
|
||||
..
|
||||
}) = &mut self.agent_backend
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
*current_session_id = Some(session_id.into());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn current_context_tokens(&self) -> u32 {
|
||||
self.current_context_tokens
|
||||
}
|
||||
@@ -2179,8 +2218,10 @@ impl AIConversation {
|
||||
});
|
||||
}
|
||||
|
||||
self.server_conversation_token =
|
||||
Some(ServerConversationToken::new(init_event.conversation_id));
|
||||
if matches!(self.agent_backend, AgentBackend::Provider) {
|
||||
self.server_conversation_token =
|
||||
Some(ServerConversationToken::new(init_event.conversation_id));
|
||||
}
|
||||
let run_id = Some(init_event.run_id).filter(|s| !s.is_empty());
|
||||
self.task_id = run_id.as_deref().and_then(|id| id.parse().ok());
|
||||
Ok(())
|
||||
@@ -3782,6 +3823,7 @@ impl AIConversation {
|
||||
.filter_map(|task| task.source_for_persistence())
|
||||
.collect(),
|
||||
conversation_data: AgentConversationData {
|
||||
agent_backend: self.agent_backend.clone(),
|
||||
server_conversation_token: self
|
||||
.server_conversation_token
|
||||
.clone()
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
ConversationStatus, RestoreConversationError,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::persistence::model::AgentConversationData;
|
||||
use crate::persistence::model::{AcpConversationData, AgentBackend, AgentConversationData};
|
||||
|
||||
fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AIConversation {
|
||||
AIConversation::new_restored(
|
||||
@@ -206,6 +206,57 @@ fn restored_conversation_uses_persisted_remote_child_marker() {
|
||||
assert!(conversation.is_remote_child());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_conversations_default_to_provider_backend() {
|
||||
let conversation = AIConversation::new(false, false);
|
||||
|
||||
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_session_id_is_updated_only_for_acp_conversations() {
|
||||
let mut acp_conversation = AIConversation::new_with_agent_backend(
|
||||
false,
|
||||
false,
|
||||
AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex-acp".to_string(),
|
||||
launch_fingerprint: "launch-123".to_string(),
|
||||
session_id: None,
|
||||
}),
|
||||
);
|
||||
assert!(acp_conversation.set_acp_session_id("session-123"));
|
||||
assert_eq!(
|
||||
acp_conversation.agent_backend(),
|
||||
&AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex-acp".to_string(),
|
||||
launch_fingerprint: "launch-123".to_string(),
|
||||
session_id: Some("session-123".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
let mut provider_conversation = AIConversation::new(false, false);
|
||||
assert!(!provider_conversation.set_acp_session_id("ignored"));
|
||||
assert_eq!(
|
||||
provider_conversation.agent_backend(),
|
||||
&AgentBackend::Provider
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_conversation_uses_persisted_acp_backend() {
|
||||
let backend = AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex-acp".to_string(),
|
||||
launch_fingerprint: "launch-123".to_string(),
|
||||
session_id: Some("session-123".to_string()),
|
||||
});
|
||||
let conversation = restored_conversation(Some(AgentConversationData {
|
||||
agent_backend: backend.clone(),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
assert_eq!(conversation.agent_backend(), &backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_conversation_detection_uses_parent_agent_id() {
|
||||
let conversation_data: AgentConversationData = serde_json::from_str(
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
|
||||
query,
|
||||
context,
|
||||
referenced_attachments,
|
||||
running_command,
|
||||
..
|
||||
} => {
|
||||
redact_secrets(query);
|
||||
@@ -40,6 +41,11 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
|
||||
referenced_attachments
|
||||
.values_mut()
|
||||
.for_each(redact_attachment);
|
||||
if let Some(running_command) = running_command {
|
||||
redact_secrets(&mut running_command.command);
|
||||
redact_secrets(&mut running_command.grid_contents);
|
||||
redact_secrets(&mut running_command.cursor);
|
||||
}
|
||||
}
|
||||
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
|
||||
redact_secrets(query);
|
||||
|
||||
@@ -222,6 +222,7 @@ fn test_title_update_refreshes_shadowing_task_title() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(server_token.to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -329,6 +330,7 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -386,6 +388,7 @@ fn test_display_status_uses_active_execution_over_previous_conversation_status()
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -450,6 +453,7 @@ fn test_display_status_updates_when_blocked_conversation_resumes() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -530,6 +534,7 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -586,6 +591,7 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -919,6 +925,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_run_id() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -975,6 +982,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_server_token() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(server_token.to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -1184,6 +1192,7 @@ fn test_resolve_open_action_returns_none_for_active_unattachable_session() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -1471,6 +1480,7 @@ fn test_server_token_assignment_updates_copy_link_resolution() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -1634,6 +1644,7 @@ fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_tok
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(token.to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -1962,6 +1973,7 @@ fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -2024,6 +2036,7 @@ fn test_get_entries_prefers_task_when_server_token_matches() {
|
||||
conversation_id,
|
||||
"root-task",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(server_token.to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -579,7 +579,7 @@ pub fn build_create_task(task_id: &str) -> ResponseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_user_query_message(task_id: &str, query_text: &str) -> ResponseEvent {
|
||||
pub(crate) fn build_user_query_message(task_id: &str, query_text: &str) -> ResponseEvent {
|
||||
let message = api::Message {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
@@ -745,7 +745,7 @@ pub fn estimate_cost_cents(
|
||||
(input_cost + output_cost + cache_read_cost + cache_write_cost) as f32
|
||||
}
|
||||
|
||||
fn build_add_agent_output_message(
|
||||
pub(crate) fn build_add_agent_output_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
initial_text: &str,
|
||||
@@ -783,7 +783,11 @@ fn build_add_agent_output_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_append_text(task_id: &str, message_id: &str, text_delta: &str) -> ResponseEvent {
|
||||
pub(crate) fn build_append_text(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
text_delta: &str,
|
||||
) -> ResponseEvent {
|
||||
let message = api::Message {
|
||||
id: message_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
|
||||
@@ -40,6 +40,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -91,6 +92,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -318,6 +318,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -372,6 +373,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -67,6 +67,7 @@ use crate::features::FeatureFlag;
|
||||
use crate::global_resource_handles::GlobalResourceHandlesProvider;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::editor::model::FileLinkResolutionContext;
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::server_api::AIApiError;
|
||||
@@ -269,6 +270,15 @@ enum RunningCommandDetection {
|
||||
Skip,
|
||||
}
|
||||
|
||||
fn acp_backend_model_id(backend: &AgentBackend) -> Option<LLMId> {
|
||||
match backend {
|
||||
AgentBackend::Provider => None,
|
||||
AgentBackend::Acp(acp) => {
|
||||
Some(format!("acp:{}", acp.agent_id.trim().to_ascii_lowercase()).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestInput {
|
||||
fn for_task(
|
||||
inputs: Vec<AIAgentInput>,
|
||||
@@ -475,6 +485,58 @@ struct InputQuery {
|
||||
queued_query_id: Option<QueuedQueryId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LiveSteeringEligibility {
|
||||
is_user_initiated: bool,
|
||||
has_shared_session_participant: bool,
|
||||
is_queued_prompt: bool,
|
||||
has_queued_query_id: bool,
|
||||
has_additional_attachments: bool,
|
||||
is_existing_task: bool,
|
||||
is_active_conversation: bool,
|
||||
has_plain_user_input: bool,
|
||||
has_pending_context: bool,
|
||||
has_action_context: bool,
|
||||
has_pending_passive_results: bool,
|
||||
}
|
||||
|
||||
impl LiveSteeringEligibility {
|
||||
fn can_attempt(self) -> bool {
|
||||
self.is_user_initiated
|
||||
&& !self.has_shared_session_participant
|
||||
&& !self.is_queued_prompt
|
||||
&& !self.has_queued_query_id
|
||||
&& !self.has_additional_attachments
|
||||
&& self.is_existing_task
|
||||
&& self.is_active_conversation
|
||||
&& self.has_plain_user_input
|
||||
&& !self.has_pending_context
|
||||
&& !self.has_action_context
|
||||
&& !self.has_pending_passive_results
|
||||
}
|
||||
}
|
||||
|
||||
fn is_plain_live_steering_input(
|
||||
input_query: &InputQueryType,
|
||||
is_same_conversation_running_command_monitor: bool,
|
||||
) -> bool {
|
||||
let InputQueryType::UserSubmittedQueryFromInput {
|
||||
query,
|
||||
static_query_type,
|
||||
running_command,
|
||||
} = input_query
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (_, user_query_mode) = extract_user_query_mode(query.clone());
|
||||
!query.trim().is_empty()
|
||||
&& !query.trim_start().starts_with('/')
|
||||
&& SlashCommandRequest::from_query(query).is_none()
|
||||
&& static_query_type.is_none()
|
||||
&& (running_command.is_none() || is_same_conversation_running_command_monitor)
|
||||
&& matches!(user_query_mode, UserQueryMode::Normal)
|
||||
}
|
||||
|
||||
impl InputQuery {
|
||||
fn query(&self) -> String {
|
||||
match &self.input_query {
|
||||
@@ -725,6 +787,7 @@ impl BlocklistAIController {
|
||||
is_queued_prompt: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let has_shared_session_participant = shared_session_participant_id.is_some();
|
||||
// Store the participant who initiated this query before sending
|
||||
// so that send_query can use it when creating the exchange.
|
||||
if let Some(participant_id) = shared_session_participant_id {
|
||||
@@ -732,6 +795,7 @@ impl BlocklistAIController {
|
||||
}
|
||||
|
||||
let query = input_query.query().to_owned();
|
||||
let is_existing_task = matches!(&input_query.which_task, WhichTask::Task { .. });
|
||||
let (conversation_id, task_id) = match input_query.which_task {
|
||||
WhichTask::NewConversation => {
|
||||
let conversation = self.start_new_conversation_for_request(ctx);
|
||||
@@ -743,6 +807,79 @@ impl BlocklistAIController {
|
||||
} => (conversation_id, task_id),
|
||||
};
|
||||
|
||||
let active_conversation_id =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_surface_id);
|
||||
let is_same_conversation_running_command_monitor = match &input_query.input_query {
|
||||
InputQueryType::UserSubmittedQueryFromInput {
|
||||
running_command: Some(running_command),
|
||||
..
|
||||
} => {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
running_command,
|
||||
)
|
||||
}
|
||||
InputQueryType::UserSubmittedQueryFromInput {
|
||||
running_command: None,
|
||||
..
|
||||
}
|
||||
| InputQueryType::AIInputType { .. } => false,
|
||||
};
|
||||
let has_simple_user_input = is_plain_live_steering_input(
|
||||
&input_query.input_query,
|
||||
is_same_conversation_running_command_monitor,
|
||||
);
|
||||
let has_pending_context = {
|
||||
let context_model = self.context_model.as_ref(ctx);
|
||||
!context_model.pending_context_block_ids().is_empty()
|
||||
|| context_model.pending_context_selected_text().is_some()
|
||||
|| !context_model.pending_attachments().is_empty()
|
||||
|| context_model.pending_document_id().is_some()
|
||||
};
|
||||
let has_action_context = {
|
||||
let action_model = self.action_model.as_ref(ctx);
|
||||
action_model.has_unfinished_actions_for_conversation(conversation_id)
|
||||
|| action_model
|
||||
.get_finished_action_results(conversation_id)
|
||||
.is_some_and(|results| !results.is_empty())
|
||||
};
|
||||
let can_attempt_live_steering = LiveSteeringEligibility {
|
||||
is_user_initiated: matches!(entrypoint_type, EntrypointType::UserInitiated),
|
||||
has_shared_session_participant,
|
||||
is_queued_prompt,
|
||||
has_queued_query_id: input_query.queued_query_id.is_some(),
|
||||
has_additional_attachments: !input_query.additional_attachments.is_empty(),
|
||||
is_existing_task,
|
||||
is_active_conversation: active_conversation_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| *id == conversation_id),
|
||||
has_plain_user_input: has_simple_user_input,
|
||||
has_pending_context,
|
||||
has_action_context,
|
||||
has_pending_passive_results: self
|
||||
.pending_passive_suggestion_results
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|results| !results.is_empty()),
|
||||
}
|
||||
.can_attempt();
|
||||
if can_attempt_live_steering {
|
||||
if let Some((stream_id, model_id)) = self
|
||||
.in_flight_response_streams
|
||||
.try_steer_acp_stream_for_conversation(conversation_id, query.clone(), ctx)
|
||||
{
|
||||
ctx.emit(BlocklistAIControllerEvent::SentRequest {
|
||||
contains_user_query: true,
|
||||
is_queued_prompt: false,
|
||||
model_id,
|
||||
stream_id,
|
||||
});
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any queued passive suggestion results for this conversation
|
||||
// *before* cancelling progress, since cancel_conversation_progress
|
||||
// clears the pending map.
|
||||
@@ -751,9 +888,6 @@ impl BlocklistAIController {
|
||||
.remove(&conversation_id)
|
||||
.unwrap_or_default();
|
||||
|
||||
let ai_history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let active_conversation_id =
|
||||
ai_history_model.active_conversation_id(self.terminal_surface_id);
|
||||
let cancellation_reason = CancellationReason::FollowUpSubmitted {
|
||||
is_for_same_conversation: active_conversation_id
|
||||
.is_some_and(|id| id == conversation_id),
|
||||
@@ -2789,7 +2923,7 @@ impl BlocklistAIController {
|
||||
/// flow that handles existing conversations properly.
|
||||
fn send_request_input(
|
||||
&mut self,
|
||||
request_input: RequestInput,
|
||||
mut request_input: RequestInput,
|
||||
query_metadata: Option<RequestMetadata>,
|
||||
can_attempt_resume_on_error: bool,
|
||||
is_queued_prompt: bool,
|
||||
@@ -2806,6 +2940,7 @@ impl BlocklistAIController {
|
||||
bedrock_history,
|
||||
bedrock_tool_result_archive,
|
||||
bedrock_progressive_summary,
|
||||
agent_backend,
|
||||
) = {
|
||||
let Some(conversation) = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -2831,9 +2966,20 @@ impl BlocklistAIController {
|
||||
conversation.bedrock_message_history().to_vec(),
|
||||
conversation.tool_result_archive().to_vec(),
|
||||
conversation.progressive_summary().map(str::to_string),
|
||||
conversation.agent_backend().clone(),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(acp_model_id) = acp_backend_model_id(&agent_backend) {
|
||||
// ACP agents own model selection. Keep every native exchange,
|
||||
// identifier, and SentRequest event from attributing this turn to
|
||||
// whichever LiteLLM/Bedrock model happens to be selected in Galaxy.
|
||||
request_input.model_id = acp_model_id.clone();
|
||||
request_input.coding_model_id = acp_model_id.clone();
|
||||
request_input.cli_agent_model_id = acp_model_id.clone();
|
||||
request_input.computer_use_model_id = acp_model_id;
|
||||
}
|
||||
|
||||
// Cancel any pending auto-resume for this conversation, since the user is sending a new
|
||||
// request.
|
||||
if let Some(handle) = self
|
||||
@@ -2954,6 +3100,7 @@ impl BlocklistAIController {
|
||||
ResponseStream::new(
|
||||
request_params.clone(),
|
||||
ai_identifiers,
|
||||
agent_backend.clone(),
|
||||
can_attempt_resume_on_error,
|
||||
ctx,
|
||||
)
|
||||
@@ -3325,7 +3472,9 @@ impl BlocklistAIController {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// If this controller is part of a shared session, forward the entire response event to viewers first.
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled() {
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled()
|
||||
&& !response_stream.as_ref(ctx).is_acp()
|
||||
{
|
||||
let mut model = self.terminal_model.lock();
|
||||
if model.shared_session_status().is_sharer() {
|
||||
// Get the participant who initiated this response, falling back to the sharer if needed.
|
||||
@@ -3360,6 +3509,18 @@ impl BlocklistAIController {
|
||||
match event {
|
||||
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
||||
history_model.update(ctx, |history_model, ctx| {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Some(session_id) = response_stream
|
||||
.as_ref(ctx)
|
||||
.acp_session_metadata()
|
||||
.and_then(|metadata| metadata.session_id)
|
||||
{
|
||||
history_model.set_acp_session_id(
|
||||
conversation_id,
|
||||
session_id,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
history_model.initialize_output_for_response_stream(
|
||||
&stream_id,
|
||||
conversation_id,
|
||||
@@ -3393,15 +3554,19 @@ impl BlocklistAIController {
|
||||
// After the stream finishes, persist the full message
|
||||
// history (input + assistant response) from the Arc back
|
||||
// into the conversation for the next request cycle.
|
||||
let messages_sent_arc =
|
||||
response_stream.as_ref(ctx).bedrock_messages_sent().clone();
|
||||
let new_history = messages_sent_arc.lock().ok().and_then(|sent| {
|
||||
if sent.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sent.clone())
|
||||
}
|
||||
});
|
||||
let new_history = (!response_stream.as_ref(ctx).is_acp())
|
||||
.then(|| {
|
||||
response_stream.as_ref(ctx).bedrock_messages_sent().clone()
|
||||
})
|
||||
.and_then(|messages_sent| {
|
||||
messages_sent.lock().ok().and_then(|sent| {
|
||||
if sent.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sent.clone())
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(mut new_history) = new_history {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
history_model.update(ctx, |history_model, _| {
|
||||
@@ -3496,8 +3661,9 @@ impl BlocklistAIController {
|
||||
const MAX_ERROR_RETRIES: usize = 2;
|
||||
let retry_count =
|
||||
self.error_retry_counts.entry(conversation_id).or_insert(0);
|
||||
let should_corrective_retry =
|
||||
is_corrective_retry_candidate && *retry_count < MAX_ERROR_RETRIES;
|
||||
let should_corrective_retry = !response_stream.as_ref(ctx).is_acp()
|
||||
&& is_corrective_retry_candidate
|
||||
&& *retry_count < MAX_ERROR_RETRIES;
|
||||
|
||||
if should_corrective_retry {
|
||||
*retry_count += 1;
|
||||
@@ -4606,6 +4772,19 @@ fn get_running_command_for_conversation(
|
||||
Some(running_command_snapshot(terminal_model))
|
||||
}
|
||||
|
||||
fn running_command_belongs_to_monitor(
|
||||
terminal_model: &TerminalModel,
|
||||
conversation_id: AIConversationId,
|
||||
running_command: &RunningCommand,
|
||||
) -> bool {
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
active_block.id() == &running_command.block_id
|
||||
&& active_block.is_agent_monitoring()
|
||||
&& active_block
|
||||
.agent_interaction_metadata()
|
||||
.is_some_and(|metadata| metadata.conversation_id() == &conversation_id)
|
||||
}
|
||||
|
||||
fn running_command_snapshot(terminal_model: &TerminalModel) -> RunningCommand {
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
let is_alt_screen_active = terminal_model.is_alt_screen_active();
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::response_stream::{ResponseStream, ResponseStreamId};
|
||||
use super::BlocklistAIController;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::CancellationReason;
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
pub(super) struct PendingResponseStreams {
|
||||
@@ -51,6 +52,29 @@ impl PendingResponseStreams {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Attempts to inject a plain-text follow-up into the active ACP turn.
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
display_text: String,
|
||||
app: &AppContext,
|
||||
) -> Option<(ResponseStreamId, LLMId)> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let conversation = history_model.conversation(&conversation_id)?;
|
||||
let (stream_id, stream) = self
|
||||
.streams
|
||||
.iter()
|
||||
.find(|(stream_id, _)| conversation.is_processing_response_stream(stream_id))?;
|
||||
let model_id = stream.as_ref(app).llm_id().clone();
|
||||
stream
|
||||
.as_ref(app)
|
||||
.try_steer_acp(display_text)
|
||||
.then(|| (stream_id.clone(), model_id))
|
||||
}
|
||||
|
||||
pub fn register_new_stream(
|
||||
&mut self,
|
||||
stream_id: ResponseStreamId,
|
||||
|
||||
@@ -3,25 +3,46 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use ::local_control::remote_command::is_potential_remote_ssh_command;
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use futures::channel::oneshot;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::acp::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, resolve_acp_launch,
|
||||
resolve_acp_permissions, validate_acp_dispatch, validate_acp_launch_identity, AcpRuntimeModel,
|
||||
AcpSessionHandleSlot, AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
||||
};
|
||||
use crate::ai::agent::api::{self, generate_multi_agent_output, ConvertToAPITypeError};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent::AIAgentInput;
|
||||
use crate::ai::agent::{AIIdentifiers, CancellationReason};
|
||||
use crate::ai::bedrock::client::BedrockClientConfig;
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::provider::ProviderConfig;
|
||||
use crate::network::NetworkStatus;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::pane_group::PaneGroup;
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::server::server_api::AIApiError;
|
||||
use crate::{report_error, send_telemetry_from_ctx, AISettings};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::settings::LocalControlSettings;
|
||||
use crate::{report_error, send_telemetry_from_ctx, AISettings, BlocklistAIHistoryModel};
|
||||
|
||||
/// Maximum number of times a single MAA request is re-sent before the failure is
|
||||
/// surfaced.
|
||||
@@ -82,6 +103,14 @@ impl ResponseStreamId {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
struct AcpRequestControl {
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
session_handle: AcpSessionHandleSlot,
|
||||
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
|
||||
}
|
||||
|
||||
/// Model wrapping an agent API response stream.
|
||||
///
|
||||
/// Emits events when the output corresponding to the stream is updated, typically after receiving
|
||||
@@ -91,6 +120,13 @@ impl ResponseStreamId {
|
||||
/// received yet, ensuring we don't retry after the AI has started executing actions.
|
||||
pub struct ResponseStream {
|
||||
id: ResponseStreamId,
|
||||
agent_backend: AgentBackend,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: AcpSessionHandleSlot,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::Sender<AcpSteeringRequest>,
|
||||
params: api::RequestParams,
|
||||
retry_count: usize,
|
||||
/// One-time fallback from the profile's thinking model to its coding model.
|
||||
@@ -157,6 +193,13 @@ impl ResponseStream {
|
||||
let (cancellation_tx, _rx) = oneshot::channel();
|
||||
Self {
|
||||
id,
|
||||
agent_backend: AgentBackend::Provider,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: Arc::new(Mutex::new(None)),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::unbounded().0,
|
||||
params: api::RequestParams::new_for_test(),
|
||||
retry_count: 0,
|
||||
coding_model_fallback_attempted: false,
|
||||
@@ -223,9 +266,155 @@ impl ResponseStream {
|
||||
ProviderConfig::None
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn resolve_acp_manager(
|
||||
backend: &crate::persistence::model::AcpConversationData,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<galaxy_acp::AcpSessionManager, String> {
|
||||
use galaxy_acp::AcpManagerConfig;
|
||||
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
||||
let configured_agent_id = if configured_agent_id.is_empty() {
|
||||
"codex"
|
||||
} else {
|
||||
configured_agent_id
|
||||
};
|
||||
let launch = resolve_acp_launch(
|
||||
configured_agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
settings.acp_agent_args.value(),
|
||||
)?;
|
||||
validate_acp_launch_identity(
|
||||
backend,
|
||||
configured_agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
&launch,
|
||||
)?;
|
||||
let config = AcpManagerConfig::new(launch);
|
||||
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn spawn_acp_request(
|
||||
backend: crate::persistence::model::AcpConversationData,
|
||||
params: api::RequestParams,
|
||||
conversation_key: String,
|
||||
request_id: Uuid,
|
||||
control: AcpRequestControl,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
} = control;
|
||||
let profile = BlocklistAIPermissions::as_ref(ctx)
|
||||
.active_permissions_profile(ctx, params.terminal_view_id);
|
||||
let permissions = resolve_acp_permissions(&profile);
|
||||
let targets_remote_terminal = params.session_context.is_remote()
|
||||
|| params.input.iter().any(|input| {
|
||||
let AIAgentInput::UserQuery {
|
||||
running_command: Some(command),
|
||||
..
|
||||
} = input
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
is_interactive_remote_command(&command.command)
|
||||
});
|
||||
let manager = validate_acp_dispatch(
|
||||
FeatureFlag::AgentClientProtocol.is_enabled(),
|
||||
*AISettings::as_ref(ctx).acp_enabled.value(),
|
||||
targets_remote_terminal,
|
||||
)
|
||||
.and_then(|()| Self::resolve_acp_manager(&backend, ctx));
|
||||
let galaxy_mcp_server = if cfg!(unix)
|
||||
&& manager.is_ok()
|
||||
&& permissions.expose_galaxy_tools
|
||||
&& FeatureFlag::GalaxyControlCli.is_enabled()
|
||||
&& LocalControlSettings::as_ref(ctx).is_enabled()
|
||||
{
|
||||
match params
|
||||
.terminal_view_id
|
||||
.and_then(|terminal_view_id| terminal_pane_id(terminal_view_id, ctx))
|
||||
{
|
||||
Some(target) => match galaxy_mcp_server(
|
||||
&target,
|
||||
permissions.allow_terminal_execute,
|
||||
permissions.allow_terminal_interrupt,
|
||||
) {
|
||||
Ok(server) => Some(server),
|
||||
Err(error) => {
|
||||
log::warn!("Galaxy MCP tools are unavailable for ACP: {error}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::warn!(
|
||||
"Galaxy MCP tools are unavailable for ACP because the originating terminal pane could not be resolved"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let galaxy_terminal_interrupt_available =
|
||||
galaxy_mcp_server.is_some() && permissions.allow_terminal_interrupt;
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let stream = match manager {
|
||||
Ok(manager) => {
|
||||
acp_output_stream(
|
||||
manager,
|
||||
params,
|
||||
conversation_key,
|
||||
backend,
|
||||
galaxy_mcp_server,
|
||||
galaxy_terminal_interrupt_available,
|
||||
permissions.policy,
|
||||
permissions.auto_approve_protocol_requests,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
cancellation_rx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(message) => acp_startup_error_stream(¶ms, &backend, &message),
|
||||
};
|
||||
Ok::<_, ConvertToAPITypeError>(stream)
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_provider_request(
|
||||
params: api::RequestParams,
|
||||
provider_config: ProviderConfig,
|
||||
request_id: Uuid,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let _ =
|
||||
ctx.spawn(
|
||||
async move {
|
||||
generate_multi_agent_output(provider_config, params, cancellation_rx).await
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
params: api::RequestParams,
|
||||
ai_identifiers: AIIdentifiers,
|
||||
agent_backend: AgentBackend,
|
||||
can_attempt_resume_on_error: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
@@ -233,18 +422,66 @@ impl ResponseStream {
|
||||
let start_time = Local::now();
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||
let params_clone = params.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
generate_multi_agent_output(provider_config, params_clone, cancellation_rx).await
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_handle = Arc::new(Mutex::new(None));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let (acp_steering_tx, acp_steering_rx) = async_channel::unbounded();
|
||||
match &agent_backend {
|
||||
AgentBackend::Provider => {
|
||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||
Self::spawn_provider_request(
|
||||
params.clone(),
|
||||
provider_config,
|
||||
request_id,
|
||||
cancellation_rx,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
AgentBackend::Acp(backend) => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::spawn_acp_request(
|
||||
backend.clone(),
|
||||
params.clone(),
|
||||
ai_identifiers
|
||||
.client_conversation_id
|
||||
.map(|id| format!("{id:?}"))
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
request_id,
|
||||
AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata: acp_session_metadata.clone(),
|
||||
session_handle: acp_session_handle.clone(),
|
||||
steering_rx: acp_steering_rx,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let error = Arc::new(AIApiError::Stream {
|
||||
stream_type: "acp",
|
||||
source: anyhow!("ACP is unavailable in the web client"),
|
||||
});
|
||||
let stream = Box::pin(futures::stream::once(async move { Err(error) }));
|
||||
let _ = ctx.spawn(
|
||||
async move { Ok::<_, ConvertToAPITypeError>(stream) },
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
id: ResponseStreamId(Uuid::new_v4().to_string()),
|
||||
agent_backend,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx,
|
||||
params: params.clone(),
|
||||
start_time,
|
||||
time_to_latest_event: TimeDelta::seconds(0),
|
||||
@@ -267,6 +504,50 @@ impl ResponseStream {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn is_acp(&self) -> bool {
|
||||
matches!(self.agent_backend, AgentBackend::Acp(_))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn acp_session_metadata(&self) -> Option<AcpSessionMetadata> {
|
||||
self.is_acp()
|
||||
.then(|| {
|
||||
self.acp_session_metadata
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|state| state.clone())
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) fn try_steer_acp(&self, display_text: String) -> bool {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
if !self.is_acp()
|
||||
|| self.current_request_id.is_none()
|
||||
|| !self
|
||||
.acp_session_metadata()
|
||||
.is_some_and(|metadata| metadata.can_steer)
|
||||
|| !self
|
||||
.acp_session_handle
|
||||
.lock()
|
||||
.is_ok_and(|session| session.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut model_text = display_text.clone();
|
||||
self.params.redact_text_for_model(&mut model_text);
|
||||
self.acp_steering_tx
|
||||
.try_send(AcpSteeringRequest::text(display_text, model_text))
|
||||
.is_ok()
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let _ = display_text;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bedrock_messages_sent(
|
||||
&self,
|
||||
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>
|
||||
@@ -279,6 +560,10 @@ impl ResponseStream {
|
||||
self.params.model.as_str()
|
||||
}
|
||||
|
||||
pub(super) fn llm_id(&self) -> &LLMId {
|
||||
&self.params.model
|
||||
}
|
||||
|
||||
/// Returns true if we should attempt to resume the conversation after the stream finishes.
|
||||
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
|
||||
self.should_resume_conversation_after_stream_finished
|
||||
@@ -334,7 +619,8 @@ impl ResponseStream {
|
||||
&self,
|
||||
error: &Arc<crate::server::server_api::AIApiError>,
|
||||
) -> bool {
|
||||
if self.coding_model_fallback_attempted || self.has_received_client_actions {
|
||||
if self.is_acp() || self.coding_model_fallback_attempted || self.has_received_client_actions
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let coding_model = self.params.coding_model.as_str();
|
||||
@@ -507,7 +793,7 @@ impl ResponseStream {
|
||||
let is_online = NetworkStatus::as_ref(ctx).is_online();
|
||||
match recovery_action(
|
||||
self.has_received_client_actions,
|
||||
e.is_recoverable(),
|
||||
e.is_recoverable() && !self.is_acp(),
|
||||
self.retry_count < MAX_RETRIES,
|
||||
self.can_attempt_resume_on_error,
|
||||
is_online,
|
||||
@@ -580,7 +866,7 @@ impl ResponseStream {
|
||||
let is_online = NetworkStatus::as_ref(ctx).is_online();
|
||||
match recovery_action(
|
||||
self.has_received_client_actions,
|
||||
unexpected_eof.is_recoverable(),
|
||||
unexpected_eof.is_recoverable() && !self.is_acp(),
|
||||
self.retry_count < MAX_RETRIES,
|
||||
self.can_attempt_resume_on_error,
|
||||
is_online,
|
||||
@@ -694,6 +980,38 @@ impl ResponseStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn is_interactive_remote_command(command: &str) -> bool {
|
||||
is_potential_remote_ssh_command(command)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn terminal_pane_id(
|
||||
terminal_view_id: galaxyui::EntityId,
|
||||
ctx: &ModelContext<ResponseStream>,
|
||||
) -> Option<GalaxyMcpTarget> {
|
||||
let window_ids = ctx.window_ids().collect::<Vec<_>>();
|
||||
for window_id in window_ids {
|
||||
let Some(pane_groups) = ctx.views_of_type::<PaneGroup>(window_id) else {
|
||||
continue;
|
||||
};
|
||||
for pane_group in pane_groups {
|
||||
let tab_id = pane_group.id().to_string();
|
||||
if let Some(pane_id) = pane_group
|
||||
.as_ref(ctx)
|
||||
.find_pane_id_for_terminal_view(terminal_view_id, ctx)
|
||||
{
|
||||
return Some(GalaxyMcpTarget {
|
||||
window_id: window_id.to_string(),
|
||||
tab_id,
|
||||
pane_id: pane_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Consumable<T> {
|
||||
value: Rc<RefCell<Option<T>>>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{recovery_action, RecoveryAction};
|
||||
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
|
||||
|
||||
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
|
||||
// can_attempt_resume_on_error, is_online.
|
||||
@@ -82,3 +82,33 @@ fn non_recoverable_post_action_failure_is_terminal() {
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
|
||||
for command in [
|
||||
"ssh user@example.com",
|
||||
"command ssh -p 2222 user@example.com",
|
||||
" /usr/bin/ssh user@example.com",
|
||||
"GALAXY_TEST=1 ssh user@example.com",
|
||||
"env GALAXY_TEST=1 ssh user@example.com",
|
||||
"/usr/bin/env -- GALAXY_TEST=1 /usr/bin/ssh user@example.com",
|
||||
"sudo -u root ssh user@example.com",
|
||||
"cd /tmp && ssh user@example.com",
|
||||
"bash -lc 'ssh user@example.com'",
|
||||
"gcloud compute ssh --zone us-central1-a instance",
|
||||
"ssh user@example.com uname -a",
|
||||
"ssh -T git@example.com",
|
||||
] {
|
||||
assert!(is_interactive_remote_command(command), "{command}");
|
||||
}
|
||||
for command in [
|
||||
"cargo test",
|
||||
"echo /usr/bin/ssh user@example.com",
|
||||
"GALAXY_TEST=/usr/bin/ssh cargo test",
|
||||
"env GALAXY_TEST=1 cargo test",
|
||||
"/usr/bin/ssh-add user@example.com",
|
||||
"bash -lc 'echo ssh user@example.com'",
|
||||
] {
|
||||
assert!(!is_interactive_remote_command(command), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAttachment, AIAgentContext, AIAgentInput, CancellationReason, ImageContext,
|
||||
PassiveSuggestionTrigger, UserQueryMode,
|
||||
PassiveSuggestionTrigger, RunningCommand, UserQueryMode,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::{
|
||||
@@ -18,6 +18,8 @@ use crate::ai::blocklist::{
|
||||
ResponseStream, ResponseStreamId,
|
||||
};
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
|
||||
|
||||
fn new_ambient_agent_task_id() -> AmbientAgentTaskId {
|
||||
@@ -41,6 +43,116 @@ fn file_attachment(file_name: &str) -> PendingAttachment {
|
||||
})
|
||||
}
|
||||
|
||||
fn live_steering_eligibility() -> super::LiveSteeringEligibility {
|
||||
super::LiveSteeringEligibility {
|
||||
is_user_initiated: true,
|
||||
has_shared_session_participant: false,
|
||||
is_queued_prompt: false,
|
||||
has_queued_query_id: false,
|
||||
has_additional_attachments: false,
|
||||
is_existing_task: true,
|
||||
is_active_conversation: true,
|
||||
has_plain_user_input: true,
|
||||
has_pending_context: false,
|
||||
has_action_context: false,
|
||||
has_pending_passive_results: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_backend_model_identity_does_not_claim_a_provider_model() {
|
||||
assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None);
|
||||
assert_eq!(
|
||||
super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: " Codex ".to_owned(),
|
||||
launch_fingerprint: "launch-123".to_owned(),
|
||||
session_id: None,
|
||||
})),
|
||||
Some(LLMId::from("acp:codex"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_steering_accepts_plain_input_for_the_existing_command_monitor() {
|
||||
let input = super::InputQueryType::UserSubmittedQueryFromInput {
|
||||
query: "Stop the command now.".to_owned(),
|
||||
static_query_type: None,
|
||||
running_command: Some(RunningCommand {
|
||||
command: "script/soak-test".to_owned(),
|
||||
block_id: BlockId::new(),
|
||||
grid_contents: "elapsed: 75s".to_owned(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(!super::is_plain_live_steering_input(&input, false));
|
||||
assert!(super::is_plain_live_steering_input(&input, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_command_monitor_identity_requires_the_same_conversation_and_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
terminal.update(&mut app, |terminal, _ctx| {
|
||||
let mut terminal_model = terminal.model.lock();
|
||||
terminal_model.simulate_long_running_block("sleep 100", "running");
|
||||
let task_id = TaskId::new("monitor-task".to_owned());
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
active_block.set_is_agent_tagged_in(true);
|
||||
active_block
|
||||
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
|
||||
.expect("tagged command should transition to agent monitoring");
|
||||
|
||||
let running_command = super::running_command_snapshot(&terminal_model);
|
||||
assert!(super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
&running_command,
|
||||
));
|
||||
assert!(!super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
AIConversationId::new(),
|
||||
&running_command,
|
||||
));
|
||||
|
||||
let mut other_block = running_command;
|
||||
other_block.block_id = BlockId::new();
|
||||
assert!(!super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
&other_block,
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_steering_retains_attachment_context_and_action_guards() {
|
||||
let eligible = live_steering_eligibility();
|
||||
assert!(eligible.can_attempt());
|
||||
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_additional_attachments: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_pending_context: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_action_context: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_suggestions_request_params_omit_ambient_agent_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -11,6 +11,7 @@ use diesel::SqliteConnection;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use itertools::Itertools as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_multi_agent_api::client_action::{Action, StartNewConversation};
|
||||
@@ -22,6 +23,8 @@ use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
||||
use super::controller::response_stream::ResponseStreamId;
|
||||
use super::persistence::{PersistedAIInput, PersistedAIInputType};
|
||||
use super::RequestInput;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::acp::acp_launch_fingerprint;
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::{
|
||||
AIConversation, AIConversationId, ConversationStatus, ServerAIConversationMetadata,
|
||||
@@ -37,11 +40,14 @@ use crate::ai::agent::{
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
use crate::persistence::model::{AgentConversation, AgentConversationData};
|
||||
use crate::persistence::model::{
|
||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::view::blocklist_filter;
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -279,6 +285,24 @@ pub struct BlocklistAIHistoryModel {
|
||||
}
|
||||
|
||||
impl BlocklistAIHistoryModel {
|
||||
/// Stores an agent-owned ACP session ID without reusing the cloud
|
||||
/// conversation-token field.
|
||||
pub(crate) fn set_acp_session_id(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
session_id: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let updated = self
|
||||
.conversations_by_id
|
||||
.get_mut(&conversation_id)
|
||||
.is_some_and(|conversation| conversation.set_acp_session_id(session_id));
|
||||
if updated {
|
||||
self.persist_conversation_state(conversation_id, ctx);
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
persisted_queries: Vec<PersistedAIInput>,
|
||||
multi_agent_conversations: &[AgentConversation],
|
||||
@@ -1171,8 +1195,43 @@ impl BlocklistAIHistoryModel {
|
||||
is_cli_agent_transcript: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> AIConversationId {
|
||||
let mut new_conversation =
|
||||
AIConversation::new(is_viewing_shared_session, is_cli_agent_transcript);
|
||||
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,
|
||||
})
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
}
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
};
|
||||
let mut new_conversation = AIConversation::new_with_agent_backend(
|
||||
is_viewing_shared_session,
|
||||
is_cli_agent_transcript,
|
||||
agent_backend,
|
||||
);
|
||||
if is_autoexecute_override {
|
||||
new_conversation.toggle_autoexecute_override();
|
||||
}
|
||||
@@ -1519,6 +1578,7 @@ impl BlocklistAIHistoryModel {
|
||||
};
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
||||
reverted_action_ids,
|
||||
@@ -1682,6 +1742,7 @@ impl BlocklistAIHistoryModel {
|
||||
// Start forked conversations without usage metadata for now; this can
|
||||
// be recomputed based on the retained exchanges in a follow-up.
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: conversation.agent_backend().for_fork(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids,
|
||||
@@ -2652,7 +2713,7 @@ impl BlocklistAIHistoryModel {
|
||||
///
|
||||
/// **Placeholder authoritative** (local orchestration linkage that the cloud
|
||||
/// transcript cannot reconstruct):
|
||||
/// - `parent_conversation_id`, `is_remote_child`, `pinned`
|
||||
/// - `agent_backend`, `parent_conversation_id`, `is_remote_child`, `pinned`
|
||||
///
|
||||
/// **Placeholder-preferred, cloud fallback** (local value wins when present,
|
||||
/// cloud's value is used otherwise so we don't lose data on a stale
|
||||
@@ -2672,6 +2733,9 @@ fn merged_remote_child_placeholder_conversation_data(
|
||||
cloud_conversation: &AIConversation,
|
||||
) -> AgentConversationData {
|
||||
AgentConversationData {
|
||||
// Placeholder authoritative.
|
||||
agent_backend: placeholder.agent_backend().clone(),
|
||||
|
||||
// Cloud authoritative.
|
||||
server_conversation_token: cloud_conversation
|
||||
.server_conversation_token()
|
||||
|
||||
@@ -1123,6 +1123,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
|
||||
|
||||
let forked_conversation_id = AIConversationId::new();
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("forked-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -3,10 +3,12 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use itertools::Itertools;
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warpui::{App, EntityId};
|
||||
use warpui::{App, EntityId, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
convert_persisted_conversation_to_ai_conversation_with_metadata, AIConversationMetadata,
|
||||
@@ -32,11 +34,13 @@ use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions};
|
||||
use crate::input_suggestions::HistoryInputSuggestion;
|
||||
use crate::persistence::model::{
|
||||
AgentConversation, AgentConversationData, AgentConversationRecord, PersistedAutoexecuteMode,
|
||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
||||
AgentConversationRecord, PersistedAutoexecuteMode,
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::test_util::ai_agent_tasks::{create_api_task, create_message};
|
||||
@@ -45,6 +49,44 @@ use crate::test_util::settings::{
|
||||
};
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn acp_enabled_with_empty_command_selects_codex_backend() {
|
||||
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(true, ctx)
|
||||
.expect("ACP setting should update");
|
||||
settings
|
||||
.acp_agent_command
|
||||
.set_value(String::new(), ctx)
|
||||
.expect("empty command should select the built-in preset");
|
||||
});
|
||||
|
||||
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, _| {
|
||||
let conversation = model
|
||||
.conversation(&conversation_id)
|
||||
.expect("conversation should exist");
|
||||
assert_eq!(
|
||||
conversation.agent_backend(),
|
||||
&AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex".to_string(),
|
||||
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
||||
session_id: None,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to create a PersistedAIInput for testing
|
||||
fn create_persisted_query(
|
||||
query_text: &str,
|
||||
@@ -772,6 +814,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi
|
||||
persisted_agent_conversation(
|
||||
child_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -795,6 +838,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi
|
||||
persisted_agent_conversation(
|
||||
parent_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -846,6 +890,7 @@ fn test_initialize_historical_conversations_uses_root_task_description_title() {
|
||||
id: 0,
|
||||
conversation_id: conversation_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("renamed-title-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -911,6 +956,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child
|
||||
persisted_agent_conversation(
|
||||
child_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -935,6 +981,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child
|
||||
persisted_agent_conversation(
|
||||
parent_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3069,6 +3116,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
|
||||
|
||||
let forked_conversation_id = AIConversationId::new();
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("forked-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3261,6 +3309,7 @@ fn test_fork_then_bind_handoff_token_resolves_to_forked_conversation() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3345,6 +3394,7 @@ fn test_fork_then_bind_handoff_token_persists_to_restored_conversation() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3454,6 +3504,7 @@ fn test_fork_then_bind_handoff_token_updates_cached_metadata_and_emits_refresh_e
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3581,6 +3632,7 @@ fn test_fork_conversation_preserves_task_ids_when_requested() {
|
||||
source_id,
|
||||
vec![root_task, subtask],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3726,6 +3778,7 @@ fn test_fork_conversation_title_override_replaces_prefix() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3816,6 +3869,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_
|
||||
placeholder_id,
|
||||
vec![placeholder_root],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3863,6 +3917,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_
|
||||
cloud_id,
|
||||
cloud_tasks.clone(),
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("cloud-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -148,6 +148,7 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() {
|
||||
server_data: String::new(),
|
||||
};
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -60,6 +60,7 @@ fn test_from_conversation_prefers_server_creator_profile() {
|
||||
"root-task",
|
||||
"/tmp/server-creator-profile",
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -224,6 +225,7 @@ fn test_from_task_includes_linked_directory_when_run_id_matches() {
|
||||
"root-task",
|
||||
directory,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -368,6 +370,7 @@ fn test_from_conversation_populates_local_conversation_fields() {
|
||||
"root-task",
|
||||
directory,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -440,6 +443,7 @@ fn test_from_task_includes_linked_directory_when_server_token_matches() {
|
||||
"root-task",
|
||||
directory,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some(server_token.to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! Warp (including Agent Mode).
|
||||
//!
|
||||
//! The side panel Warp AI implementation lives in `super::ai_assistant`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod acp;
|
||||
pub(crate) mod active_agent_views_model;
|
||||
pub(crate) mod agent;
|
||||
pub(crate) mod agent_conversations_model;
|
||||
@@ -75,6 +77,8 @@ pub mod outline;
|
||||
pub(crate) use ai::paths;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
||||
blocklist::keyboard_navigable_buttons::init(app);
|
||||
blocklist::block::number_shortcut_buttons::init(app);
|
||||
blocklist::toggleable_items::init(app);
|
||||
|
||||
@@ -47,6 +47,8 @@ fn enabled_features() -> HashSet<FeatureFlag> {
|
||||
FeatureFlag::CreatingSharedSessions,
|
||||
#[cfg(feature = "agent_mode")]
|
||||
FeatureFlag::AgentMode,
|
||||
#[cfg(feature = "agent_client_protocol")]
|
||||
FeatureFlag::AgentClientProtocol,
|
||||
#[cfg(feature = "shared_session_long_running_commands")]
|
||||
FeatureFlag::SharedSessionWriteToLongRunningCommands,
|
||||
#[cfg(feature = "resize_fix")]
|
||||
|
||||
@@ -10,7 +10,7 @@ use ::local_control::{
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::local_control::handlers::{
|
||||
app_state, close, metadata, metadata_config, settings_surfaces,
|
||||
app_state, close, metadata, metadata_config, settings_surfaces, terminal,
|
||||
};
|
||||
use crate::local_control::permissions::{
|
||||
ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version,
|
||||
@@ -150,6 +150,15 @@ impl LocalControlBridge {
|
||||
}
|
||||
ActionKind::SessionList => metadata::session_list(&request.target, ctx),
|
||||
ActionKind::SessionInspect => metadata::session_inspect(&request.target, ctx),
|
||||
ActionKind::TerminalStatus
|
||||
| ActionKind::TerminalExecute
|
||||
| ActionKind::TerminalInterrupt => terminal::handle(
|
||||
&self.instance_id,
|
||||
request.action.kind,
|
||||
&request.action.params,
|
||||
&request.target,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::ThemeList => settings_surfaces::theme_list(ctx),
|
||||
ActionKind::ThemeGet => settings_surfaces::theme_get(ctx),
|
||||
ActionKind::ThemeSet
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(super) mod layout;
|
||||
pub(super) mod metadata;
|
||||
pub(super) mod metadata_config;
|
||||
pub(super) mod settings_surfaces;
|
||||
pub(super) mod terminal;
|
||||
|
||||
/// Standard acknowledgement payload shared by mutation handlers.
|
||||
pub(crate) fn ack(instance_id: &Option<InstanceId>, action: ActionKind) -> serde_json::Value {
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Race-safe control of commands in existing visible terminal sessions.
|
||||
#[cfg(test)]
|
||||
#[path = "terminal_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use ::local_control::protocol::{
|
||||
TargetSelector, TerminalExecuteParams, TerminalInterruptParams, TerminalStatusResult,
|
||||
};
|
||||
use ::local_control::remote_command::is_potential_remote_ssh_command;
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
|
||||
use chrono::{DateTime, Local};
|
||||
use serde_json::json;
|
||||
use warpui::ModelContext;
|
||||
|
||||
use crate::ai::agent::redaction::redact_secrets;
|
||||
use crate::local_control::resolver::{decode_params, target_pane_group, target_session_pane_id};
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::terminal::model::escape_sequences::C0;
|
||||
use crate::terminal::view::TerminalView;
|
||||
|
||||
const MAX_TERMINAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||
const MAX_COMMAND_SUMMARY_CHARS: usize = 1_024;
|
||||
const MAX_BLOCK_ID_BYTES: usize = 4 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ActiveBlockSnapshot {
|
||||
block_id: String,
|
||||
is_executing: bool,
|
||||
is_command_pending: bool,
|
||||
is_long_running: bool,
|
||||
is_agent_in_control: bool,
|
||||
is_potential_remote_ssh: bool,
|
||||
running_for_ms: Option<u64>,
|
||||
command_summary: Option<String>,
|
||||
}
|
||||
|
||||
impl ActiveBlockSnapshot {
|
||||
fn is_idle(&self) -> bool {
|
||||
!self.is_executing && !self.is_command_pending && !self.is_long_running
|
||||
}
|
||||
|
||||
fn has_running_command(&self) -> bool {
|
||||
self.is_executing || self.is_command_pending || self.is_long_running
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle(
|
||||
instance_id: &Option<InstanceId>,
|
||||
action: ActionKind,
|
||||
params: &serde_json::Value,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
match action {
|
||||
ActionKind::TerminalStatus => terminal_status(target, ctx),
|
||||
ActionKind::TerminalExecute => {
|
||||
let TerminalExecuteParams { command } = decode_params(params)?;
|
||||
validate_terminal_command(&command)?;
|
||||
terminal_execute(instance_id, target, command, ctx)
|
||||
}
|
||||
ActionKind::TerminalInterrupt => {
|
||||
let TerminalInterruptParams { block_id } = decode_params(params)?;
|
||||
validate_block_id(&block_id)?;
|
||||
terminal_interrupt(instance_id, target, block_id, ctx)
|
||||
}
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!("{} is not a terminal control action", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_status(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let (session_id, terminal_view) = resolve_terminal(ActionKind::TerminalStatus, target, ctx)?;
|
||||
let snapshot = terminal_view.read(ctx, |terminal_view, ctx| {
|
||||
ensure_terminal_session_local(
|
||||
ActionKind::TerminalStatus,
|
||||
terminal_view.active_session_is_local(ctx),
|
||||
)?;
|
||||
let snapshot = active_block_snapshot(terminal_view);
|
||||
ensure_active_block_is_local(ActionKind::TerminalStatus, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
})?;
|
||||
let is_idle = snapshot.is_idle();
|
||||
serde_json::to_value(TerminalStatusResult {
|
||||
action: ActionKind::TerminalStatus,
|
||||
session_id,
|
||||
active_block_id: snapshot.block_id,
|
||||
is_executing: snapshot.is_executing,
|
||||
is_command_pending: snapshot.is_command_pending,
|
||||
is_long_running: snapshot.is_long_running,
|
||||
is_agent_in_control: snapshot.is_agent_in_control,
|
||||
is_idle,
|
||||
running_for_ms: snapshot.running_for_ms,
|
||||
command_summary: snapshot.command_summary,
|
||||
})
|
||||
.map_err(|error| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to serialize terminal status",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_execute(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
command: String,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let (session_id, terminal_view) = resolve_terminal(ActionKind::TerminalExecute, target, ctx)?;
|
||||
terminal_view.update(ctx, |terminal_view, ctx| {
|
||||
ensure_terminal_session_local(
|
||||
ActionKind::TerminalExecute,
|
||||
terminal_view.active_session_is_local(ctx),
|
||||
)?;
|
||||
let snapshot = active_block_snapshot(terminal_view);
|
||||
ensure_active_block_is_local(ActionKind::TerminalExecute, &snapshot)?;
|
||||
ensure_terminal_idle(&snapshot)?;
|
||||
let pending_input = terminal_view
|
||||
.input()
|
||||
.read(ctx, |input, ctx| input.buffer_text(ctx));
|
||||
ensure_terminal_input_empty(&pending_input)?;
|
||||
|
||||
terminal_view.write_to_pty(terminal_command_bytes(command), ctx);
|
||||
|
||||
Ok(json!({
|
||||
"action": ActionKind::TerminalExecute.as_str(),
|
||||
"ok": true,
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
"session_id": session_id,
|
||||
"previous_block_id": snapshot.block_id,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_interrupt(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
expected_block_id: String,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let (session_id, terminal_view) = resolve_terminal(ActionKind::TerminalInterrupt, target, ctx)?;
|
||||
terminal_view.update(ctx, |terminal_view, ctx| {
|
||||
ensure_terminal_session_local(
|
||||
ActionKind::TerminalInterrupt,
|
||||
terminal_view.active_session_is_local(ctx),
|
||||
)?;
|
||||
let snapshot = active_block_snapshot(terminal_view);
|
||||
ensure_active_block_is_local(ActionKind::TerminalInterrupt, &snapshot)?;
|
||||
ensure_interrupt_target(&snapshot, &expected_block_id)?;
|
||||
terminal_view.write_to_pty(vec![C0::ETX], ctx);
|
||||
|
||||
Ok(json!({
|
||||
"action": ActionKind::TerminalInterrupt.as_str(),
|
||||
"ok": true,
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
"session_id": session_id,
|
||||
"block_id": snapshot.block_id,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_terminal(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(String, warpui::ViewHandle<TerminalView>), ControlError> {
|
||||
let pane_group = target_pane_group(action, target, ctx)?;
|
||||
let pane_id = target_session_pane_id(action, target, &pane_group, ctx)?;
|
||||
let terminal_view = pane_group
|
||||
.read(ctx, |pane_group, ctx| {
|
||||
pane_group.terminal_view_from_pane_id(pane_id, ctx)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires an existing terminal session", action.as_str()),
|
||||
)
|
||||
})?;
|
||||
Ok((pane_id.to_string(), terminal_view))
|
||||
}
|
||||
|
||||
fn active_block_snapshot(terminal_view: &TerminalView) -> ActiveBlockSnapshot {
|
||||
let model = terminal_view.model.lock();
|
||||
let active_block = model.block_list().active_block();
|
||||
let is_executing = active_block.is_executing();
|
||||
let is_command_pending = active_block.is_command_grid_active();
|
||||
let is_long_running = active_block.is_active_and_long_running();
|
||||
let mut command = active_block.command_with_secrets_obfuscated(false);
|
||||
let is_potential_remote_ssh = is_potential_remote_ssh_command(&command);
|
||||
redact_secrets(&mut command);
|
||||
ActiveBlockSnapshot {
|
||||
block_id: active_block.id().to_string(),
|
||||
is_executing,
|
||||
is_command_pending,
|
||||
is_long_running,
|
||||
is_agent_in_control: active_block.is_agent_in_control(),
|
||||
is_potential_remote_ssh,
|
||||
running_for_ms: elapsed_millis(
|
||||
active_block.start_ts(),
|
||||
Local::now(),
|
||||
is_executing || is_command_pending || is_long_running,
|
||||
),
|
||||
command_summary: safe_command_summary(&command),
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_millis(
|
||||
started_at: Option<&DateTime<Local>>,
|
||||
now: DateTime<Local>,
|
||||
is_running: bool,
|
||||
) -> Option<u64> {
|
||||
if !is_running {
|
||||
return None;
|
||||
}
|
||||
started_at.map(|started_at| {
|
||||
now.signed_duration_since(started_at)
|
||||
.num_milliseconds()
|
||||
.max(0) as u64
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_terminal_command(command: &str) -> Result<(), ControlError> {
|
||||
if command.trim().is_empty() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"terminal.execute requires a non-empty command",
|
||||
));
|
||||
}
|
||||
if command.as_bytes().contains(&0) {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"terminal.execute rejects NUL bytes",
|
||||
));
|
||||
}
|
||||
if command.len() > MAX_TERMINAL_COMMAND_BYTES {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("terminal.execute command exceeds the {MAX_TERMINAL_COMMAND_BYTES}-byte limit"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminal_command_bytes(command: String) -> Vec<u8> {
|
||||
let mut bytes = command.into_bytes();
|
||||
bytes.push(C0::CR);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn validate_block_id(block_id: &str) -> Result<(), ControlError> {
|
||||
if block_id.is_empty()
|
||||
|| block_id.len() > MAX_BLOCK_ID_BYTES
|
||||
|| block_id.as_bytes().contains(&0)
|
||||
{
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"terminal.interrupt requires a valid non-empty active block_id",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_terminal_idle(snapshot: &ActiveBlockSnapshot) -> Result<(), ControlError> {
|
||||
if snapshot.is_idle() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"terminal.execute requires an idle terminal; active block {} is still running",
|
||||
snapshot.block_id
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_terminal_input_empty(input: &str) -> Result<(), ControlError> {
|
||||
if input.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
"terminal.execute will not overwrite pending user input; clear or submit the terminal input first",
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_active_block_is_local(
|
||||
action: ActionKind,
|
||||
snapshot: &ActiveBlockSnapshot,
|
||||
) -> Result<(), ControlError> {
|
||||
if !snapshot.has_running_command() || !snapshot.is_potential_remote_ssh {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"{} is unavailable because the target terminal's active command may be an SSH-backed remote session",
|
||||
action.as_str()
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_terminal_session_local(
|
||||
action: ActionKind,
|
||||
active_session_is_local: Option<bool>,
|
||||
) -> Result<(), ControlError> {
|
||||
match active_session_is_local {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"{} is unavailable because the target terminal's active session is remote",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
None => Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"{} requires an active terminal session whose locality Galaxy can verify",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_interrupt_target(
|
||||
snapshot: &ActiveBlockSnapshot,
|
||||
expected_block_id: &str,
|
||||
) -> Result<(), ControlError> {
|
||||
if snapshot.block_id != expected_block_id {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!(
|
||||
"terminal.interrupt expected block {expected_block_id}, but the active block is {}",
|
||||
snapshot.block_id
|
||||
),
|
||||
));
|
||||
}
|
||||
if !snapshot.has_running_command() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"terminal.interrupt block {} is not executing",
|
||||
snapshot.block_id
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_command_summary(command: &str) -> Option<String> {
|
||||
let command = command.trim();
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut summary = command
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.take(MAX_COMMAND_SUMMARY_CHARS)
|
||||
.collect::<String>();
|
||||
if command.chars().count() > MAX_COMMAND_SUMMARY_CHARS {
|
||||
summary.push('…');
|
||||
}
|
||||
Some(summary)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use ::local_control::{ActionKind, ErrorCode};
|
||||
use chrono::{Local, TimeDelta};
|
||||
|
||||
use super::{
|
||||
elapsed_millis, ensure_active_block_is_local, ensure_interrupt_target, ensure_terminal_idle,
|
||||
ensure_terminal_input_empty, ensure_terminal_session_local, safe_command_summary,
|
||||
terminal_command_bytes, validate_block_id, validate_terminal_command, ActiveBlockSnapshot,
|
||||
MAX_COMMAND_SUMMARY_CHARS, MAX_TERMINAL_COMMAND_BYTES,
|
||||
};
|
||||
use crate::terminal::model::escape_sequences::C0;
|
||||
|
||||
fn snapshot(block_id: &str) -> ActiveBlockSnapshot {
|
||||
ActiveBlockSnapshot {
|
||||
block_id: block_id.to_owned(),
|
||||
is_executing: false,
|
||||
is_command_pending: false,
|
||||
is_long_running: false,
|
||||
is_agent_in_control: false,
|
||||
is_potential_remote_ssh: false,
|
||||
running_for_ms: None,
|
||||
command_summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_command_validation_rejects_empty_nul_and_oversized_input() {
|
||||
assert!(validate_terminal_command("cargo test").is_ok());
|
||||
|
||||
for command in ["", " ", "echo before\0echo after"] {
|
||||
let error = validate_terminal_command(command).expect_err("command is rejected");
|
||||
assert_eq!(error.code, ErrorCode::InvalidParams);
|
||||
}
|
||||
|
||||
let oversized = "x".repeat(MAX_TERMINAL_COMMAND_BYTES + 1);
|
||||
let error = validate_terminal_command(&oversized).expect_err("oversized command is rejected");
|
||||
assert_eq!(error.code, ErrorCode::InvalidParams);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_requires_an_idle_active_block() {
|
||||
let idle = snapshot("block-1");
|
||||
assert!(ensure_terminal_idle(&idle).is_ok());
|
||||
|
||||
for busy in [
|
||||
ActiveBlockSnapshot {
|
||||
is_executing: true,
|
||||
..idle.clone()
|
||||
},
|
||||
ActiveBlockSnapshot {
|
||||
is_command_pending: true,
|
||||
..idle.clone()
|
||||
},
|
||||
ActiveBlockSnapshot {
|
||||
is_long_running: true,
|
||||
..idle.clone()
|
||||
},
|
||||
] {
|
||||
let error = ensure_terminal_idle(&busy).expect_err("busy terminal is rejected");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_rejects_pending_user_input_without_treating_whitespace_as_empty() {
|
||||
assert!(ensure_terminal_input_empty("").is_ok());
|
||||
|
||||
for pending_input in ["cargo check", " ", "\n"] {
|
||||
let error = ensure_terminal_input_empty(pending_input)
|
||||
.expect_err("pending terminal input must be preserved");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_requires_a_verified_local_active_session() {
|
||||
for action in [
|
||||
ActionKind::TerminalStatus,
|
||||
ActionKind::TerminalExecute,
|
||||
ActionKind::TerminalInterrupt,
|
||||
] {
|
||||
assert!(ensure_terminal_session_local(action, Some(true)).is_ok());
|
||||
|
||||
let error =
|
||||
ensure_terminal_session_local(action, Some(false)).expect_err("remote is rejected");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
assert!(error.message.contains("active session is remote"));
|
||||
|
||||
let error = ensure_terminal_session_local(action, None)
|
||||
.expect_err("an unverified session is rejected");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
assert!(error.message.contains("locality Galaxy can verify"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_rejects_a_running_recognized_ssh_command() {
|
||||
let mut running_ssh = snapshot("block-ssh");
|
||||
running_ssh.is_executing = true;
|
||||
running_ssh.is_potential_remote_ssh = true;
|
||||
|
||||
for action in [
|
||||
ActionKind::TerminalStatus,
|
||||
ActionKind::TerminalExecute,
|
||||
ActionKind::TerminalInterrupt,
|
||||
] {
|
||||
let error = ensure_active_block_is_local(action, &running_ssh)
|
||||
.expect_err("running SSH is rejected");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
assert!(error.message.contains("SSH-backed remote session"));
|
||||
}
|
||||
|
||||
running_ssh.is_executing = false;
|
||||
assert!(ensure_active_block_is_local(ActionKind::TerminalExecute, &running_ssh).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_requires_matching_running_block() {
|
||||
let mut running = snapshot("block-2");
|
||||
running.is_executing = true;
|
||||
assert!(ensure_interrupt_target(&running, "block-2").is_ok());
|
||||
|
||||
let stale =
|
||||
ensure_interrupt_target(&running, "block-1").expect_err("stale expected block is rejected");
|
||||
assert_eq!(stale.code, ErrorCode::StaleTarget);
|
||||
|
||||
let idle = snapshot("block-2");
|
||||
let error =
|
||||
ensure_interrupt_target(&idle, "block-2").expect_err("idle block cannot be interrupted");
|
||||
assert_eq!(error.code, ErrorCode::TargetStateConflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_id_validation_rejects_empty_or_nul_values() {
|
||||
assert!(validate_block_id("session-1-42").is_ok());
|
||||
for block_id in ["", "bad\0id"] {
|
||||
let error = validate_block_id(block_id).expect_err("invalid block id is rejected");
|
||||
assert_eq!(error.code, ErrorCode::InvalidParams);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_summary_is_bounded_and_omits_empty_commands() {
|
||||
assert_eq!(safe_command_summary(" "), None);
|
||||
assert_eq!(
|
||||
safe_command_summary(" cargo test "),
|
||||
Some("cargo test".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
safe_command_summary("printf 'one\\ntwo'\nnext"),
|
||||
Some("printf 'one\\ntwo' next".to_owned())
|
||||
);
|
||||
|
||||
let command = "x".repeat(MAX_COMMAND_SUMMARY_CHARS + 1);
|
||||
let summary = safe_command_summary(&command).expect("non-empty summary");
|
||||
assert_eq!(summary.chars().count(), MAX_COMMAND_SUMMARY_CHARS + 1);
|
||||
assert!(summary.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_duration_is_present_only_for_an_active_command() {
|
||||
let now = Local::now();
|
||||
let started_at = now - TimeDelta::seconds(75);
|
||||
|
||||
assert_eq!(elapsed_millis(Some(&started_at), now, true), Some(75_000));
|
||||
assert_eq!(elapsed_millis(Some(&started_at), now, false), None);
|
||||
assert_eq!(elapsed_millis(None, now, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_appends_a_terminal_enter_sequence() {
|
||||
assert_eq!(
|
||||
terminal_command_bytes("cargo test".to_owned()),
|
||||
[b"cargo test".as_slice(), &[C0::CR]].concat()
|
||||
);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ fn surface_list_rejects_target_selectors() {
|
||||
|
||||
#[test]
|
||||
fn capabilities_advertises_the_complete_catalog() {
|
||||
assert_eq!(capabilities().len(), 77);
|
||||
assert_eq!(capabilities().len(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,8 +3,8 @@ use ::local_control::protocol::{
|
||||
ActionNameParams, ActionParameterSpec, BindingNameParams, BooleanValueParams, ColorValueParams,
|
||||
DirectionParams, EmptyParams, FileOpenParams, KeyParams, KeyValueParams, NamespaceParams,
|
||||
PageQueryParams, PaneTarget, QueryParams, RenameParams, ResizeParams, SessionTarget,
|
||||
TabActivateParams, TabCloseParams, TabCreateParams, TabTarget, TargetSelector, TextParams,
|
||||
ThemeNameParams, WindowTarget,
|
||||
TabActivateParams, TabCloseParams, TabCreateParams, TabTarget, TargetSelector,
|
||||
TerminalExecuteParams, TerminalInterruptParams, TextParams, ThemeNameParams, WindowTarget,
|
||||
};
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, TargetScope};
|
||||
use warpui::{AppContext, ModelContext, TypedActionView, ViewHandle, WindowId};
|
||||
@@ -49,6 +49,8 @@ pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result
|
||||
ActionParameterSpec::TabActivate => parse_params::<TabActivateParams>(action),
|
||||
ActionParameterSpec::TabClose => parse_params::<TabCloseParams>(action),
|
||||
ActionParameterSpec::TabCreate => parse_params::<TabCreateParams>(action),
|
||||
ActionParameterSpec::TerminalExecute => parse_params::<TerminalExecuteParams>(action),
|
||||
ActionParameterSpec::TerminalInterrupt => parse_params::<TerminalInterruptParams>(action),
|
||||
ActionParameterSpec::Text => parse_params::<TextParams>(action),
|
||||
ActionParameterSpec::ThemeName => parse_params::<ThemeNameParams>(action),
|
||||
}
|
||||
|
||||
@@ -382,6 +382,7 @@ fn persisted_remote_child_conversation(
|
||||
id: 0,
|
||||
conversation_id: conversation_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("restored-child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -1226,6 +1226,50 @@ define_settings_group!(AISettings, settings: [
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
}
|
||||
// Whether new local agent conversations should use an Agent Client Protocol backend.
|
||||
acp_enabled: AcpEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "ai.acp.enabled",
|
||||
description: "Whether new local agent conversations use an Agent Client Protocol backend.",
|
||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||
}
|
||||
// Stable identifier for the selected ACP agent preset.
|
||||
acp_agent_id: AcpAgentId {
|
||||
type: String,
|
||||
default: "codex".to_string(),
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "ai.acp.agent_id",
|
||||
description: "Identifier for the local Agent Client Protocol agent preset.",
|
||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||
}
|
||||
// Executable used to launch the configured local ACP agent.
|
||||
acp_agent_command: AcpAgentCommand {
|
||||
type: String,
|
||||
default: String::new(),
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "ai.acp.agent_command",
|
||||
description: "Executable used to launch the local Agent Client Protocol agent.",
|
||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||
}
|
||||
// Arguments passed directly to the configured ACP agent executable.
|
||||
acp_agent_args: AcpAgentArgs {
|
||||
type: Vec<String>,
|
||||
default: Vec::new(),
|
||||
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "ai.acp.agent_args",
|
||||
description: "Arguments passed to the local Agent Client Protocol agent executable.",
|
||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||
}
|
||||
// Whether to use locally loaded AWS credentials for Bedrock-enabled requests.
|
||||
bedrock_enabled: BedrockEnabled {
|
||||
type: bool,
|
||||
|
||||
@@ -80,7 +80,7 @@ use crate::editor::{
|
||||
use crate::modal::{Modal, ModalEvent, ModalViewState};
|
||||
use crate::settings::ai::BedrockAuthMethod;
|
||||
use crate::settings::{
|
||||
AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent,
|
||||
AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent, AcpEnabled,
|
||||
AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist,
|
||||
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin,
|
||||
BedrockEnabled, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled, FileBasedMcpEnabled,
|
||||
@@ -190,6 +190,18 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
context: &ContextPredicate,
|
||||
builder: fn(SettingsAction) -> T,
|
||||
) {
|
||||
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
|
||||
vec![ToggleSettingActionPair::new(
|
||||
"Agent Client Protocol",
|
||||
builder(SettingsAction::AI(AISettingsPageAction::ToggleAcpEnabled)),
|
||||
context,
|
||||
flags::ACP_ENABLED_FLAG,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::WarpAi)
|
||||
.with_enabled(|| cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled())],
|
||||
app,
|
||||
);
|
||||
|
||||
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
|
||||
vec![ToggleSettingActionPair::new(
|
||||
"AI",
|
||||
@@ -1867,6 +1879,9 @@ impl AISettingsPageView {
|
||||
}
|
||||
widgets.push(Box::new(CloudHandoffWidget::default()));
|
||||
widgets.push(Box::new(CLIAgentWidget::default()));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
|
||||
}
|
||||
widgets.push(Box::new(AgentAttributionWidget::default()));
|
||||
widgets.push(Box::new(OtherAIWidget::default()));
|
||||
}
|
||||
@@ -1903,6 +1918,9 @@ impl AISettingsPageView {
|
||||
widgets.push(Box::new(VoiceWidget::default()));
|
||||
}
|
||||
widgets.push(Box::new(CloudHandoffWidget::default()));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
|
||||
}
|
||||
if FeatureFlag::CustomModelRouters.is_enabled() {
|
||||
widgets.push(Box::new(CustomModelRoutersWidget));
|
||||
}
|
||||
@@ -2697,6 +2715,7 @@ pub enum AISettingsPageAction {
|
||||
SetBedrockProfile(String),
|
||||
ToggleBedrockCrossRegionInference,
|
||||
ToggleOpenAIEnabled,
|
||||
ToggleAcpEnabled,
|
||||
FetchOpenAIModels,
|
||||
ToggleFileBasedMcp,
|
||||
ToggleIncludeAgentCommandsInHistory,
|
||||
@@ -3455,6 +3474,14 @@ impl TypedActionView for AISettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleAcpEnabled => {
|
||||
if cfg!(unix) {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.acp_enabled.toggle_and_save_value(ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
AISettingsPageAction::FetchOpenAIModels => {
|
||||
// Trigger a fetch of models from the LiteLLM endpoint
|
||||
self.fetch_litellm_models(ctx);
|
||||
@@ -7560,6 +7587,247 @@ impl SettingsWidget for BedrockSettingsWidget {
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPSettingsWidget {
|
||||
enabled_toggle: SwitchStateHandle,
|
||||
agent_id_editor: ViewHandle<EditorView>,
|
||||
command_editor: ViewHandle<EditorView>,
|
||||
args_editor: ViewHandle<EditorView>,
|
||||
}
|
||||
|
||||
impl ACPSettingsWidget {
|
||||
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let is_enabled = *settings.acp_enabled.value();
|
||||
let agent_id = settings.acp_agent_id.value().clone();
|
||||
let command = settings.acp_agent_command.value().clone();
|
||||
let args = serde_json::to_string(settings.acp_agent_args.value())
|
||||
.unwrap_or_else(|_| "[]".to_owned());
|
||||
|
||||
let agent_id_editor = Self::editor(agent_id, "codex or opencode", false, ctx);
|
||||
ctx.subscribe_to_view(&agent_id_editor, |_, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let value = editor.as_ref(ctx).buffer_text(ctx);
|
||||
if !value.trim().is_empty() {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.acp_agent_id.set_value(value, ctx));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let command_editor = Self::editor(
|
||||
command,
|
||||
"Leave empty to use the version-pinned preset",
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
ctx.subscribe_to_view(&command_editor, |_, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let value = editor.as_ref(ctx).buffer_text(ctx);
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.acp_agent_command.set_value(value, ctx));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let args_editor = Self::editor(args, r#"["arg1", "arg2"]"#, false, ctx);
|
||||
ctx.subscribe_to_view(&args_editor, |_, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let value = editor.as_ref(ctx).buffer_text(ctx);
|
||||
match serde_json::from_str::<Vec<String>>(&value) {
|
||||
Ok(args) => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.acp_agent_args.set_value(args, ctx));
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("ACP agent arguments must be a JSON string array: {error}");
|
||||
let saved_args =
|
||||
serde_json::to_string(AISettings::as_ref(ctx).acp_agent_args.value())
|
||||
.unwrap_or_else(|_| "[]".to_owned());
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&saved_args, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for editor in [
|
||||
agent_id_editor.clone(),
|
||||
command_editor.clone(),
|
||||
args_editor.clone(),
|
||||
] {
|
||||
AISettingsPageView::update_editor_interaction_state(editor, is_enabled, ctx);
|
||||
}
|
||||
|
||||
let agent_id_editor_clone = agent_id_editor.clone();
|
||||
let command_editor_clone = command_editor.clone();
|
||||
let args_editor_clone = args_editor.clone();
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| {
|
||||
if matches!(event, AISettingsChangedEvent::AcpEnabled { .. }) {
|
||||
let is_enabled = *AISettings::as_ref(ctx).acp_enabled.value();
|
||||
for editor in [
|
||||
agent_id_editor_clone.clone(),
|
||||
command_editor_clone.clone(),
|
||||
args_editor_clone.clone(),
|
||||
] {
|
||||
AISettingsPageView::update_editor_interaction_state(editor, is_enabled, ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
enabled_toggle: SwitchStateHandle::default(),
|
||||
agent_id_editor,
|
||||
command_editor,
|
||||
args_editor,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor(
|
||||
text: String,
|
||||
placeholder: &'static str,
|
||||
is_password: bool,
|
||||
ctx: &mut ViewContext<AISettingsPageView>,
|
||||
) -> ViewHandle<EditorView> {
|
||||
ctx.add_typed_action_view(move |ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let options = SingleLineEditorOptions {
|
||||
is_password,
|
||||
text: TextOptions {
|
||||
font_size_override: Some(appearance.ui_font_size()),
|
||||
font_family_override: Some(appearance.monospace_font_family()),
|
||||
text_colors_override: Some(TextColors {
|
||||
default_color: appearance.theme().active_ui_text_color(),
|
||||
disabled_color: appearance.theme().disabled_ui_text_color(),
|
||||
hint_color: appearance.theme().disabled_ui_text_color(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text(placeholder, ctx);
|
||||
editor.set_buffer_text(&text, ctx);
|
||||
editor
|
||||
})
|
||||
}
|
||||
|
||||
fn render_input(
|
||||
appearance: &Appearance,
|
||||
label: &'static str,
|
||||
editor: ViewHandle<EditorView>,
|
||||
is_enabled: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let style = UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 10.,
|
||||
bottom: 10.,
|
||||
left: 16.,
|
||||
right: 16.,
|
||||
}),
|
||||
background: Some(appearance.theme().surface_2().into()),
|
||||
..Default::default()
|
||||
};
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(
|
||||
Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE)
|
||||
.with_color(styles::header_font_color(is_enabled, app).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(editor)
|
||||
.with_style(style)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsWidget for ACPSettingsWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"acp agent client protocol codex opencode subscription local agent"
|
||||
}
|
||||
|
||||
fn should_render(&self, _app: &AppContext) -> bool {
|
||||
cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let settings = AISettings::as_ref(app);
|
||||
let is_enabled = *settings.acp_enabled.value();
|
||||
let mut column = Flex::column().with_spacing(16.);
|
||||
|
||||
column.add_child(build_sub_header(appearance, "Agent Client Protocol", None).finish());
|
||||
column.add_child(render_ai_setting_toggle::<AcpEnabled>(
|
||||
"Use an ACP agent for new conversations",
|
||||
AISettingsPageAction::ToggleAcpEnabled,
|
||||
is_enabled,
|
||||
true,
|
||||
self.enabled_toggle.clone(),
|
||||
&RefCell::new(HashMap::new()),
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"ACP agents own their model and login. The Codex preset prefers its advertised ChatGPT sign-in, while custom agents use their first advertised ACP authentication method; tokens remain owned by the agent. Galaxy keeps the native transcript, cancellation, image uploads, and pane-pinned Galaxy Control tools.",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"The built-in Codex adapter starts in read-only mode. Galaxy currently denies adapter-native read, search, edit, delete, move, execute, fetch, and uncategorized permission requests because ACP’s broad categories do not carry enough command, path, or MCP identity to enforce detailed allowlists safely. Agent thinking remains available. Pane-pinned Galaxy tools are exposed only when the active execution profile permits them. Custom ACP agents must honor the protocol’s permission contract.",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_separator(appearance));
|
||||
column.add_child(Self::render_input(
|
||||
appearance,
|
||||
"Agent preset",
|
||||
self.agent_id_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Use “codex” for the pinned Codex ACP adapter or “opencode” for OpenCode. Codex can launch through npx or Bun. Galaxy prefers an installed OpenCode binary; its package fallback requires npx/Node.js.",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(Self::render_input(
|
||||
appearance,
|
||||
"Custom executable (optional)",
|
||||
self.command_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(Self::render_input(
|
||||
appearance,
|
||||
"Custom arguments (JSON array)",
|
||||
self.args_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"ACP agents are trusted local programs. Custom arguments apply only when a custom executable is set; built-in presets ignore them. Galaxy removes inherited environment values outside a small runtime allowlist, and custom arguments are stored as plain-text settings. Only configure executables you trust, and never place API keys or access tokens in their arguments. Existing ACP sessions refuse to run after the effective executable, preset version, arguments, environment, or authentication selection changes; restore that configuration or start a new conversation.",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAISettingsWidget {
|
||||
enabled_toggle: SwitchStateHandle,
|
||||
base_url_editor: ViewHandle<EditorView>,
|
||||
|
||||
@@ -547,6 +547,7 @@ pub mod flags {
|
||||
pub const SUGGESTED_RULES_FLAG: &str = "Suggested_Rules";
|
||||
pub const WARP_DRIVE_CONTEXT_FLAG: &str = "Warp_Drive_Context";
|
||||
pub const FILE_BASED_MCP_FLAG: &str = "File_Based_MCP";
|
||||
pub const ACP_ENABLED_FLAG: &str = "Agent_Client_Protocol_Enabled";
|
||||
pub const SHOW_BASE_MODEL_PICKER_IN_PROMPT_FLAG: &str = "Show_Base_Model_Picker_In_Prompt";
|
||||
pub const DEBUG_SHOW_MEMORY_STATS_FLAG: &str = "Debug_Memory_Statistics";
|
||||
pub const ALLOW_NATIVE_WAYLAND: &str = "Allow_Native_Wayland";
|
||||
|
||||
@@ -20,6 +20,7 @@ use instant::{Duration, Instant};
|
||||
use parking_lot::FairMutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
|
||||
const SIDECAR_POSITION_ID: &str = "model_sidecar_panel";
|
||||
|
||||
@@ -29,6 +30,7 @@ use galaxy_core::ui::color::{coloru_with_opacity, Opacity};
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
|
||||
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::blocklist::prompt::PromptIconButtonTheme;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIInputEvent, BlocklistAIInputModel,
|
||||
@@ -53,6 +55,8 @@ use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::context_chips::display_chip::{udi_font_size, udi_icon_size};
|
||||
use crate::context_chips::spacing;
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::settings::AISettings;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::{MenuPositioning, MenuPositioningProvider};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
@@ -482,6 +486,24 @@ impl ProfileModelSelector {
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
let changes_active_conversation = matches!(
|
||||
event,
|
||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
|
||||
);
|
||||
if changes_active_conversation
|
||||
&& event.terminal_surface_id() == Some(me.terminal_view_id)
|
||||
{
|
||||
me.is_model_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| {
|
||||
me.handle_appearance_change(ctx);
|
||||
});
|
||||
@@ -655,6 +677,24 @@ impl ProfileModelSelector {
|
||||
self.is_locked_for_cloud_followup(app) || self.is_locked_for_non_oz_run(app)
|
||||
}
|
||||
|
||||
fn is_acp_model_managed(&self, app: &AppContext) -> bool {
|
||||
if self.ambient_agent_view_model.is_some() {
|
||||
return false;
|
||||
}
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
if let Some(conversation_id) = history.active_conversation_id(self.terminal_view_id) {
|
||||
return history
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|conversation| {
|
||||
matches!(conversation.agent_backend(), AgentBackend::Acp(_))
|
||||
});
|
||||
}
|
||||
|
||||
cfg!(unix)
|
||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
&& *AISettings::as_ref(app).acp_enabled.value()
|
||||
}
|
||||
|
||||
/// True when a non-Oz harness is selected.
|
||||
fn is_third_party_harness(&self, app: &AppContext) -> bool {
|
||||
self.ambient_agent_view_model.as_ref().is_some_and(|m| {
|
||||
@@ -1595,6 +1635,7 @@ impl ProfileModelSelector {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let llm_preferences = LLMPreferences::as_ref(app);
|
||||
let is_acp_model_managed = self.is_acp_model_managed(app);
|
||||
|
||||
// Allow editing if composing an ambient agent query, or if the user has edit access
|
||||
// in a shared session (i.e., not a viewer, or is an executor).
|
||||
@@ -1617,7 +1658,9 @@ impl ProfileModelSelector {
|
||||
.is_agent_in_control_or_tagged_in();
|
||||
drop(terminal_model);
|
||||
|
||||
let model_display_name = if self.is_third_party_harness(app) {
|
||||
let model_display_name = if is_acp_model_managed {
|
||||
"Managed by ACP".to_owned()
|
||||
} else if self.is_third_party_harness(app) {
|
||||
self.harness_model_display_name(app)
|
||||
} else if is_lrc {
|
||||
llm_preferences
|
||||
@@ -1674,7 +1717,8 @@ impl ProfileModelSelector {
|
||||
// Only show chevron icon if the user can click to open the menu (i.e. has edit access)
|
||||
// and the InlineMenuHeaders feature flag is not enabled
|
||||
// (when enabled, clicking opens the inline model selector instead of a dropdown).
|
||||
if has_edit_access && !FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
if has_edit_access && !is_acp_model_managed && !FeatureFlag::InlineMenuHeaders.is_enabled()
|
||||
{
|
||||
let chevron_icon = Icon::ChevronDown
|
||||
.to_galaxyui_icon(Fill::Solid(text_color))
|
||||
.finish();
|
||||
@@ -1702,7 +1746,7 @@ impl ProfileModelSelector {
|
||||
let is_locked_for_followup = self.is_locked_for_cloud_followup(app);
|
||||
let is_locked_for_non_oz = self.is_locked_for_non_oz_run(app);
|
||||
let is_locked = is_locked_for_followup || is_locked_for_non_oz;
|
||||
let can_interact = has_edit_access && !is_locked;
|
||||
let can_interact = has_edit_access && !is_locked && !is_acp_model_managed;
|
||||
|
||||
let hoverable = Hoverable::new(self.model_mouse_state.clone(), move |state| {
|
||||
if state.is_hovered() && can_interact {
|
||||
@@ -1730,7 +1774,9 @@ impl ProfileModelSelector {
|
||||
stack.finish()
|
||||
} else if state.is_hovered() {
|
||||
// Non-Oz runs lock silently — skip the tooltip entirely.
|
||||
let tooltip_text: Option<&str> = if is_locked_for_followup {
|
||||
let tooltip_text: Option<&str> = if is_acp_model_managed {
|
||||
Some("Model selection is managed by the ACP agent")
|
||||
} else if is_locked_for_followup {
|
||||
Some(MODEL_LOCKED_FOR_FOLLOWUP_TOOLTIP)
|
||||
} else if is_locked_for_non_oz {
|
||||
None
|
||||
@@ -1803,6 +1849,19 @@ impl TypedActionView for ProfileModelSelector {
|
||||
type Action = ProfileModelSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
let is_model_action = matches!(
|
||||
action,
|
||||
ProfileModelSelectorAction::SelectModel(_)
|
||||
| ProfileModelSelectorAction::SelectAutoModel
|
||||
| ProfileModelSelectorAction::SelectReasoningModel(_)
|
||||
| ProfileModelSelectorAction::SelectHarnessModel { .. }
|
||||
| ProfileModelSelectorAction::ToggleModelMenu
|
||||
);
|
||||
if is_model_action && self.is_acp_model_managed(ctx) {
|
||||
self.set_model_menu_visibility(false, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
match action {
|
||||
ProfileModelSelectorAction::SelectProfile(profile_id) => {
|
||||
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
|
||||
@@ -1891,6 +1950,7 @@ impl View for ProfileModelSelector {
|
||||
let theme = appearance.theme();
|
||||
let profiles_model = AIExecutionProfilesModel::as_ref(app);
|
||||
let has_multiple_profiles = profiles_model.has_multiple_profiles();
|
||||
let is_acp_model_managed = self.is_acp_model_managed(app);
|
||||
|
||||
// Check if user is a viewer in a shared session
|
||||
let is_viewer = self
|
||||
@@ -1914,12 +1974,14 @@ impl View for ProfileModelSelector {
|
||||
compact_row.add_child(profile_button_with_save_position);
|
||||
}
|
||||
|
||||
let model_button_with_save_position = SavePosition::new(
|
||||
ChildView::new(&self.model_compact_button).finish(),
|
||||
"profile_model_selector_model_compact_button",
|
||||
)
|
||||
.finish();
|
||||
compact_row.add_child(model_button_with_save_position);
|
||||
if !is_acp_model_managed {
|
||||
let model_button_with_save_position = SavePosition::new(
|
||||
ChildView::new(&self.model_compact_button).finish(),
|
||||
"profile_model_selector_model_compact_button",
|
||||
)
|
||||
.finish();
|
||||
compact_row.add_child(model_button_with_save_position);
|
||||
}
|
||||
|
||||
let compact_layout = compact_row.finish();
|
||||
|
||||
@@ -1965,7 +2027,7 @@ impl View for ProfileModelSelector {
|
||||
stack.add_positioned_overlay_child(profile_menu, positioning);
|
||||
}
|
||||
|
||||
if self.is_model_menu_open {
|
||||
if self.is_model_menu_open && !is_acp_model_managed {
|
||||
let model_menu = ChildView::new(&self.model_dropdown).finish();
|
||||
let positioning = self.get_menu_positioning(app, false);
|
||||
stack.add_positioned_overlay_child(model_menu, positioning);
|
||||
@@ -1977,7 +2039,8 @@ impl View for ProfileModelSelector {
|
||||
// The popup overflows the viewport on wasm mobile.
|
||||
let is_wasm_mobile = warpui::platform::is_mobile_device();
|
||||
|
||||
if !is_wasm_mobile
|
||||
if !is_acp_model_managed
|
||||
&& !is_wasm_mobile
|
||||
&& (is_udi_enabled
|
||||
|| self
|
||||
.input_model
|
||||
|
||||
+105
-33
@@ -163,38 +163,37 @@ impl SshWarpifyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref INTERACTIVE_SSH: Regex = Regex::new(r"^ssh\s+").expect("interactive SSH regex invalid");
|
||||
|
||||
/// Matches "gcloud compute ssh" for connecting to GCP VMs.
|
||||
static ref GCLOUD_REGEX: Regex = Regex::new(r"^gcloud\s+compute\s+ssh\s.+").expect("gcloud SSH regex invalid");
|
||||
|
||||
/// Matches "eb ssh" for connecting to AWS Elastic Beanstalk VMs.
|
||||
static ref ELASTIC_BEANSTALK_REGEX: Regex = Regex::new(r"^eb\s+ssh\s.+").expect("elastic beanstalk SSH regex invalid");
|
||||
|
||||
/// Matches "doctl compute ssh" for connecting to a digital ocean droplet.
|
||||
static ref DIGITAL_OCEAN_DROPLET_REGEX: Regex = Regex::new(r"^doctl\s+compute\s+ssh\s.+").expect("digital ocean SSH regex invalid");
|
||||
}
|
||||
|
||||
impl SshWarpifyCommand {
|
||||
pub fn matches(command: &str) -> Option<SshWarpifyCommand> {
|
||||
let command = if let Some(suffix) = command.strip_prefix("command ") {
|
||||
suffix
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if INTERACTIVE_SSH.is_match(command) {
|
||||
Some(SshWarpifyCommand::Ssh)
|
||||
} else if GCLOUD_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud))
|
||||
} else if ELASTIC_BEANSTALK_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk))
|
||||
} else if DIGITAL_OCEAN_DROPLET_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(
|
||||
SshLikeCommand::DigitalOceanDroplet,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
let tokens = normalized_command_tokens(command)?;
|
||||
match tokens.as_slice() {
|
||||
[command, arguments @ ..] if command == "ssh" && !arguments.is_empty() => {
|
||||
Some(SshWarpifyCommand::Ssh)
|
||||
}
|
||||
[command, compute, ssh, arguments @ ..]
|
||||
if command == "gcloud"
|
||||
&& compute == "compute"
|
||||
&& ssh == "ssh"
|
||||
&& !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud))
|
||||
}
|
||||
[command, ssh, arguments @ ..]
|
||||
if command == "eb" && ssh == "ssh" && !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk))
|
||||
}
|
||||
[command, compute, ssh, arguments @ ..]
|
||||
if command == "doctl"
|
||||
&& compute == "compute"
|
||||
&& ssh == "ssh"
|
||||
&& !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(
|
||||
SshLikeCommand::DigitalOceanDroplet,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,9 +215,7 @@ pub fn parse_interactive_ssh_command(command: &str) -> Option<InteractiveSshComm
|
||||
}
|
||||
|
||||
fn parse_ssh_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
let Ok(tokens) = shell_words::split(command) else {
|
||||
return None;
|
||||
};
|
||||
let tokens = normalized_command_tokens(command)?;
|
||||
|
||||
// Cases: "", "ls", "ssh-add-key"
|
||||
if tokens.is_empty() || tokens[0] != "ssh" {
|
||||
@@ -227,6 +224,81 @@ fn parse_ssh_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
Some(tokens)
|
||||
}
|
||||
|
||||
/// Returns shell tokens with safe, non-executing prefixes removed and the
|
||||
/// executable reduced to its basename. This lets SSH detection recognize the
|
||||
/// command forms users commonly launch from a shell without treating an
|
||||
/// argument that merely contains "ssh" as an SSH process.
|
||||
fn normalized_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
let tokens = shell_words::split(command.trim_start()).ok()?;
|
||||
let mut command_index = 0;
|
||||
|
||||
while tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| is_environment_assignment(token))
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
|
||||
if tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| executable_name(token) == "command")
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
|
||||
if tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| executable_name(token) == "env")
|
||||
{
|
||||
command_index += 1;
|
||||
while let Some(token) = tokens.get(command_index) {
|
||||
if is_environment_assignment(token)
|
||||
|| matches!(
|
||||
token.as_str(),
|
||||
"-i" | "--ignore-environment" | "-0" | "--null"
|
||||
)
|
||||
{
|
||||
command_index += 1;
|
||||
} else if token == "--" {
|
||||
command_index += 1;
|
||||
while tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| is_environment_assignment(token))
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let command_name = executable_name(tokens.get(command_index)?);
|
||||
let mut normalized = tokens[command_index..].to_vec();
|
||||
normalized[0] = command_name;
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn is_environment_assignment(token: &str) -> bool {
|
||||
let Some((name, _)) = token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = name.chars();
|
||||
chars
|
||||
.next()
|
||||
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
|
||||
&& chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn executable_name(executable: &str) -> String {
|
||||
let file_name = executable.rsplit(['/', '\\']).next().unwrap_or(executable);
|
||||
file_name
|
||||
.strip_suffix(".exe")
|
||||
.unwrap_or(file_name)
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Creates an sftp command that copies a given local file into the pwd in the warpified ssh session.
|
||||
pub fn transfer_file_sftp_command(
|
||||
local_file_path: String,
|
||||
|
||||
@@ -133,3 +133,38 @@ fn ssh_interactive_shell_parsing() {
|
||||
== Some("localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_interactive_shell_parsing_normalizes_safe_shell_prefixes() {
|
||||
for command in [
|
||||
" ssh user@host",
|
||||
"/usr/bin/ssh user@host",
|
||||
"GALAXY_TEST=1 ssh user@host",
|
||||
"env GALAXY_TEST=1 ssh user@host",
|
||||
"command /usr/bin/ssh user@host",
|
||||
"/usr/bin/env -i GALAXY_TEST=1 /usr/bin/ssh user@host",
|
||||
"/usr/bin/env -- GALAXY_TEST=1 /usr/bin/ssh user@host",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_interactive_ssh_command(command).and_then(|parsed| parsed.host),
|
||||
Some("user@host".to_owned()),
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_interactive_shell_parsing_does_not_match_ssh_arguments_or_similar_names() {
|
||||
for command in [
|
||||
"echo /usr/bin/ssh user@host",
|
||||
"GALAXY_TEST=/usr/bin/ssh cargo test",
|
||||
"env GALAXY_TEST=1 cargo test",
|
||||
"/usr/bin/ssh-add user@host",
|
||||
"sh -c 'echo ssh user@host'",
|
||||
] {
|
||||
assert!(
|
||||
parse_interactive_ssh_command(command).is_none(),
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,6 +936,7 @@ impl TerminalView {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -22868,6 +22868,9 @@ impl Workspace {
|
||||
if *ai_settings.file_based_mcp_enabled.value() {
|
||||
context.set.insert(flags::FILE_BASED_MCP_FLAG);
|
||||
}
|
||||
if cfg!(unix) && *ai_settings.acp_enabled.value() {
|
||||
context.set.insert(flags::ACP_ENABLED_FLAG);
|
||||
}
|
||||
if *session_settings.show_model_selectors_in_prompt.value() {
|
||||
context
|
||||
.set
|
||||
|
||||
Reference in New Issue
Block a user