Add ACP agent backend and terminal controls

This commit is contained in:
2026-07-30 07:25:11 -05:00
parent dbfa8bcd48
commit ad24374f6d
84 changed files with 12151 additions and 157 deletions
+183
View File
@@ -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;
+140
View File
@@ -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());
}
+23
View File
@@ -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,
};
+46
View File
@@ -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;
+84
View File
@@ -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);
}
+334
View File
@@ -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 &params.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;
+248
View File
@@ -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([&regex], 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(&params, 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(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Fix this"));
assert!(text.text.contains("hidden_from_transcript"));
assert!(text.text.contains("broken()"));
assert!(text.text.contains("Run tests first."));
}
#[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(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Handle the Galaxy system request"));
assert!(text.text.contains("Repair the failing unit test."));
assert!(text.text.contains("hidden_from_transcript"));
}
#[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(
&params,
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(
&params,
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(&params, 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")
);
}
+335
View File
@@ -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;
+351
View File
@@ -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(_))
));
}
+42
View File
@@ -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 {}
+354
View File
@@ -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(&params, &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(&params, 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;
+114
View File
@@ -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(&params).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(&params), None);
}