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
+469
View File
@@ -0,0 +1,469 @@
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol::schema::v1::AuthMethodId;
use agent_client_protocol::AcpAgentConfig;
use crate::{DenyByDefaultPermissionHandler, PermissionHandler};
/// Pinned version of the official Codex ACP adapter.
pub const CODEX_ACP_NPM_VERSION: &str = "1.1.7";
/// Pinned version of OpenCode used by the built-in ACP launch preset.
pub const OPENCODE_NPM_VERSION: &str = "1.18.9";
const DEFAULT_CANCELLATION_GRACE_PERIOD: Duration = Duration::from_secs(5);
const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg(any(windows, test))]
const DEFAULT_WINDOWS_EXECUTABLE_EXTENSIONS: &[&str] = &[".COM", ".EXE", ".BAT", ".CMD"];
/// A built-in, version-pinned ACP agent launch preset.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AcpAgentPreset {
/// The official adapter around the OpenAI Codex app server.
Codex,
/// OpenCode's native `acp` command.
OpenCode,
}
impl AcpAgentPreset {
/// Builds the pinned launch configuration for this preset.
#[must_use]
pub fn launch_config(self) -> AcpLaunchConfig {
match self {
Self::Codex => AcpLaunchConfig::new("npx")
.args(vec![
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
])
.preferred_auth_method("chat-gpt")
// The adapter owns the browser OAuth flow and persists credentials in
// Codex's normal auth store. Galaxy only selects the advertised ACP
// method; it never receives or stores ChatGPT tokens.
.env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#)
// Codex ACP otherwise defaults to workspace-write mode, where
// in-sandbox edits and commands can bypass ACP permission
// requests. Start read-only so every mutation is mediated by
// Galaxy's execution profile (or explicit Run to Completion).
.env("INITIAL_AGENT_MODE", "read-only"),
Self::OpenCode => AcpLaunchConfig::new("npx").args(vec![
"--yes".to_owned(),
format!("opencode-ai@{OPENCODE_NPM_VERSION}"),
"acp".to_owned(),
]),
}
}
/// Resolves the best available executable for this preset.
///
/// OpenCode's native binary is preferred when installed. The Codex adapter
/// uses `npx` when available and can run through Bun's Node compatibility
/// mode. OpenCode's npm wrapper requires Node during installation.
pub fn resolve_launch_config(self) -> Result<AcpLaunchConfig, String> {
self.resolve_launch_config_with(executable_on_path)
}
fn resolve_launch_config_with(
self,
mut resolve: impl FnMut(&str) -> Option<PathBuf>,
) -> Result<AcpLaunchConfig, String> {
match self {
Self::Codex => {
let (command, args) = if let Some(command) = resolve("npx") {
(
command,
vec![
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
],
)
} else if let Some(command) = resolve("bunx") {
(
command,
vec![
"--bun".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
],
)
} else {
return Err(
"Codex ACP requires npx or bunx; install Node.js/npm or Bun, or configure a custom ACP executable"
.to_owned(),
);
};
Ok(AcpLaunchConfig::new(command)
.args(args)
.preferred_auth_method("chat-gpt")
.env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#)
.env("INITIAL_AGENT_MODE", "read-only"))
}
Self::OpenCode => {
if let Some(command) = resolve("opencode") {
return Ok(AcpLaunchConfig::new(command).args(["acp"]));
}
if let Some(command) = resolve("npx") {
return Ok(AcpLaunchConfig::new(command).args([
"--yes".to_owned(),
format!("opencode-ai@{OPENCODE_NPM_VERSION}"),
"acp".to_owned(),
]));
}
Err(
"OpenCode ACP requires the opencode executable or npx; install OpenCode or Node.js/npm, or configure a custom ACP executable"
.to_owned(),
)
}
}
}
}
/// Comparable ACP agent launch settings.
///
/// Galaxy can retain this value as a fingerprint and restart its manager when
/// the selected executable, arguments, environment, or authentication method
/// changes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AcpLaunchConfig {
/// Executable to spawn.
pub command: PathBuf,
/// Arguments passed to the executable.
pub args: Vec<String>,
/// Environment variables added to or overridden in the child process.
///
/// Galaxy clears inherited values outside a small runtime allowlist before
/// applying these explicit overrides.
pub env: BTreeMap<String, String>,
/// Authentication method to select when the agent advertises more than one.
///
/// When unset, Galaxy selects the first method advertised by the agent.
pub preferred_auth_method: Option<AuthMethodId>,
}
impl AcpLaunchConfig {
/// Creates launch settings for an executable.
#[must_use]
pub fn new(command: impl Into<PathBuf>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
env: BTreeMap::new(),
preferred_auth_method: None,
}
}
/// Replaces the arguments passed to the executable.
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args = args.into_iter().map(Into::into).collect();
self
}
/// Adds or overrides an environment variable in the child process.
#[must_use]
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(name.into(), value.into());
self
}
/// Selects a specific authentication method from the agent's advertised
/// methods.
#[must_use]
pub fn preferred_auth_method(mut self, method_id: impl Into<AuthMethodId>) -> Self {
self.preferred_auth_method = Some(method_id.into());
self
}
/// Points the Codex adapter at a particular Codex executable.
#[must_use]
pub fn codex_path(self, path: impl AsRef<Path>) -> Self {
self.env("CODEX_PATH", path.as_ref().to_string_lossy())
}
/// Resolves a configured executable through `PATH` and returns a clear
/// startup error before an ACP worker is created.
pub fn resolve_command(mut self) -> Result<Self, String> {
let command = if self.command.components().count() > 1 || self.command.is_absolute() {
self.command
.is_file()
.then(|| self.command.clone())
.ok_or_else(|| {
format!(
"ACP executable does not exist or is not a file: {}",
self.command.display()
)
})?
} else {
let command = self.command.to_string_lossy();
executable_on_path(&command)
.ok_or_else(|| format!("ACP executable was not found on PATH: {command}"))?
};
self.command = command;
Ok(self)
}
pub(crate) fn to_agent_config(&self) -> AcpAgentConfig {
AcpAgentConfig::new(self.command.clone())
.args(self.args.clone())
.envs(sanitized_environment_overrides(
std::env::vars_os(),
&self.env,
))
}
}
fn sanitized_environment_overrides(
parent_environment: impl IntoIterator<Item = (OsString, OsString)>,
explicit_environment: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut overrides = BTreeMap::new();
for (name, _) in parent_environment {
let Some(name) = name.to_str() else {
continue;
};
if !may_inherit_environment_variable(name) {
overrides.insert(name.to_owned(), String::new());
}
}
overrides.extend(explicit_environment.clone());
overrides
}
fn may_inherit_environment_variable(name: &str) -> bool {
let name = name.to_ascii_uppercase();
matches!(
name.as_str(),
"PATH"
| "HOME"
| "USER"
| "LOGNAME"
| "SHELL"
| "TMPDIR"
| "TMP"
| "TEMP"
| "LANG"
| "LANGUAGE"
| "LC_ALL"
| "LC_ADDRESS"
| "LC_COLLATE"
| "LC_CTYPE"
| "LC_IDENTIFICATION"
| "LC_MEASUREMENT"
| "LC_MESSAGES"
| "LC_MONETARY"
| "LC_NAME"
| "LC_NUMERIC"
| "LC_PAPER"
| "LC_TELEPHONE"
| "LC_TIME"
| "TZ"
| "TERM"
| "COLORTERM"
| "NO_COLOR"
| "FORCE_COLOR"
| "DISPLAY"
| "WAYLAND_DISPLAY"
| "XAUTHORITY"
| "DBUS_SESSION_BUS_ADDRESS"
| "SSL_CERT_FILE"
| "SSL_CERT_DIR"
| "NODE_EXTRA_CA_CERTS"
| "NODE_PATH"
| "NPM_CONFIG_PREFIX"
| "BUN_INSTALL"
| "SYSTEMROOT"
| "WINDIR"
| "COMSPEC"
| "PATHEXT"
| "PROGRAMDATA"
| "PROGRAMFILES"
| "PROGRAMFILES(X86)"
| "COMMONPROGRAMFILES"
| "COMMONPROGRAMFILES(X86)"
| "APPDATA"
| "LOCALAPPDATA"
| "USERPROFILE"
| "HOMEDRIVE"
| "HOMEPATH"
| "NUMBER_OF_PROCESSORS"
| "PROCESSOR_ARCHITECTURE"
| "PROCESSOR_IDENTIFIER"
| "XDG_CACHE_HOME"
| "XDG_CONFIG_DIRS"
| "XDG_CONFIG_HOME"
| "XDG_DATA_DIRS"
| "XDG_DATA_HOME"
| "XDG_RUNTIME_DIR"
| "XDG_STATE_HOME"
| "__CF_USER_TEXT_ENCODING"
)
}
fn executable_on_path(command: &str) -> Option<PathBuf> {
let path = Path::new(command);
if path.components().count() > 1 || path.is_absolute() {
return path.is_file().then(|| path.to_owned());
}
let path = std::env::var_os("PATH")?;
let executable_extensions = platform_executable_extensions();
find_executable_in_directories(
command,
std::env::split_paths(&path),
&executable_extensions,
)
}
fn find_executable_in_directories(
command: &str,
directories: impl IntoIterator<Item = PathBuf>,
executable_extensions: &[String],
) -> Option<PathBuf> {
for directory in directories {
let candidate = directory.join(command);
if candidate.is_file() {
return Some(candidate);
}
if Path::new(command).extension().is_none() {
for extension in executable_extensions {
let candidate = directory.join(format!("{command}{extension}"));
if candidate.is_file() {
return Some(candidate);
}
}
}
}
None
}
#[cfg(windows)]
fn platform_executable_extensions() -> Vec<String> {
windows_executable_extensions(std::env::var_os("PATHEXT").as_deref())
}
#[cfg(any(windows, test))]
fn windows_executable_extensions(path_extensions: Option<&std::ffi::OsStr>) -> Vec<String> {
let configured = path_extensions.map_or_else(Vec::new, |path_extensions| {
let path_extensions = path_extensions.to_string_lossy();
path_extensions
.split(';')
.map(str::trim)
.filter(|extension| !extension.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>()
});
if configured.is_empty() {
DEFAULT_WINDOWS_EXECUTABLE_EXTENSIONS
.iter()
.map(ToString::to_string)
.collect()
} else {
configured
}
}
#[cfg(not(windows))]
fn platform_executable_extensions() -> Vec<String> {
Vec::new()
}
/// Configuration for an [`AcpSessionManager`](crate::AcpSessionManager).
#[derive(Clone)]
pub struct AcpManagerConfig {
/// Comparable settings used to launch the ACP agent.
pub launch: AcpLaunchConfig,
/// Programmatic client name sent during ACP initialization.
pub client_name: String,
/// Client version sent during ACP initialization.
pub client_version: String,
/// How long a cancelled prompt may remain active before its subprocess is
/// torn down.
pub cancellation_grace_period: Duration,
/// Maximum time allowed for process startup and ACP initialization.
///
/// Timing out drops the connection future, which tears down the ACP child
/// process and its process group.
pub initialization_timeout: Duration,
/// Maximum time allowed for an agent-owned authentication flow.
///
/// Browser login is intentionally given more time than process startup,
/// but remains bounded so an abandoned flow cannot strand queued turns or
/// cancellation requests indefinitely.
pub authentication_timeout: Duration,
/// Permission hook. The supplied default denies requests unless a turn
/// explicitly opts into automatic approval.
pub permission_handler: Arc<dyn PermissionHandler>,
}
impl AcpManagerConfig {
/// Creates a manager configuration with safe permission defaults.
#[must_use]
pub fn new(launch: AcpLaunchConfig) -> Self {
Self {
launch,
client_name: "galaxy".to_owned(),
client_version: env!("CARGO_PKG_VERSION").to_owned(),
cancellation_grace_period: DEFAULT_CANCELLATION_GRACE_PERIOD,
initialization_timeout: DEFAULT_INITIALIZATION_TIMEOUT,
authentication_timeout: DEFAULT_AUTHENTICATION_TIMEOUT,
permission_handler: Arc::new(DenyByDefaultPermissionHandler),
}
}
/// Creates a configuration from a pinned built-in preset.
#[must_use]
pub fn preset(preset: AcpAgentPreset) -> Self {
Self::new(preset.launch_config())
}
/// Replaces the permission decision hook.
#[must_use]
pub fn permission_handler(mut self, handler: Arc<dyn PermissionHandler>) -> Self {
self.permission_handler = handler;
self
}
/// Replaces the process-startup and ACP-initialization timeout.
#[must_use]
pub fn initialization_timeout(mut self, timeout: Duration) -> Self {
self.initialization_timeout = timeout;
self
}
/// Replaces the agent-owned authentication timeout.
#[must_use]
pub fn authentication_timeout(mut self, timeout: Duration) -> Self {
self.authentication_timeout = timeout;
self
}
}
impl fmt::Debug for AcpManagerConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AcpManagerConfig")
.field("launch", &self.launch)
.field("client_name", &self.client_name)
.field("client_version", &self.client_version)
.field("cancellation_grace_period", &self.cancellation_grace_period)
.field("initialization_timeout", &self.initialization_timeout)
.field("authentication_timeout", &self.authentication_timeout)
.field("permission_handler", &"<permission handler>")
.finish()
}
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;
+273
View File
@@ -0,0 +1,273 @@
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::*;
#[test]
fn codex_preset_is_version_pinned() {
let launch = AcpAgentPreset::Codex.launch_config();
assert_eq!(launch.command, PathBuf::from("npx"));
assert_eq!(
launch.args,
vec![
"--yes",
&format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}")
]
);
assert_eq!(
launch.env.get("DEFAULT_AUTH_REQUEST").map(String::as_str),
Some(r#"{"methodId":"chat-gpt"}"#)
);
assert_eq!(
launch.env.get("INITIAL_AGENT_MODE").map(String::as_str),
Some("read-only")
);
assert_eq!(
launch
.preferred_auth_method
.as_ref()
.map(ToString::to_string),
Some("chat-gpt".to_owned())
);
}
#[test]
fn opencode_preset_is_version_pinned() {
let launch = AcpAgentPreset::OpenCode.launch_config();
assert_eq!(launch.command, PathBuf::from("npx"));
assert_eq!(
launch.args,
vec![
"--yes",
&format!("opencode-ai@{OPENCODE_NPM_VERSION}"),
"acp"
]
);
}
#[test]
fn resolved_opencode_prefers_the_native_executable() {
let launch = AcpAgentPreset::OpenCode
.resolve_launch_config_with(|command| match command {
"opencode" => Some(PathBuf::from("/opt/bin/opencode")),
"npx" => Some(PathBuf::from("/opt/bin/npx")),
_ => None,
})
.unwrap();
assert_eq!(launch.command, PathBuf::from("/opt/bin/opencode"));
assert_eq!(launch.args, vec!["acp"]);
}
#[test]
fn resolved_codex_falls_back_to_bun_compatibility_mode() {
let resolve = |command: &str| (command == "bunx").then(|| PathBuf::from("/opt/bin/bunx"));
let codex = AcpAgentPreset::Codex
.resolve_launch_config_with(resolve)
.unwrap();
assert_eq!(codex.command, PathBuf::from("/opt/bin/bunx"));
assert_eq!(
codex.args,
vec![
"--bun".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}")
]
);
assert_eq!(
codex.env.get("INITIAL_AGENT_MODE").map(String::as_str),
Some("read-only")
);
assert_eq!(
codex
.preferred_auth_method
.as_ref()
.map(ToString::to_string),
Some("chat-gpt".to_owned())
);
}
#[test]
fn resolved_presets_explain_missing_launchers() {
let error = AcpAgentPreset::Codex
.resolve_launch_config_with(|_| None)
.unwrap_err();
assert!(error.contains("requires npx or bunx"));
let opencode_error = AcpAgentPreset::OpenCode
.resolve_launch_config_with(|command| {
(command == "bunx").then(|| PathBuf::from("/opt/bin/bunx"))
})
.unwrap_err();
assert!(opencode_error.contains("requires the opencode executable or npx"));
}
#[test]
fn launch_config_is_a_comparable_fingerprint() {
let first = AcpLaunchConfig::new("/usr/bin/npx")
.args(["agent", "acp"])
.env("TOKEN", "first")
.preferred_auth_method("browser");
let same = first.clone();
let different = first.clone().env("TOKEN", "second");
let different_auth = first.clone().preferred_auth_method("api-key");
assert_eq!(first, same);
assert_ne!(first, different);
assert_ne!(first, different_auth);
}
#[test]
fn child_environment_clears_credentials_and_preserves_runtime_paths() {
let parent = [
("PATH", "/usr/bin"),
("HOME", "/Users/test"),
("XDG_CONFIG_HOME", "/Users/test/.config"),
("OPENAI_API_KEY", "secret-openai-key"),
("AWS_SECRET_ACCESS_KEY", "secret-aws-key"),
("HTTPS_PROXY", "https://user:secret@example.com"),
("GALAXY_INTERNAL_SECRET", "secret-galaxy-value"),
("XDG_AGENT_TOKEN", "secret-xdg-value"),
("LC_AGENT_TOKEN", "secret-locale-value"),
]
.map(|(name, value)| (OsString::from(name), OsString::from(value)));
let explicit = BTreeMap::from([
("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned()),
(
"DEFAULT_AUTH_REQUEST".to_owned(),
r#"{"methodId":"chat-gpt"}"#.to_owned(),
),
]);
let overrides = sanitized_environment_overrides(parent, &explicit);
assert!(!overrides.contains_key("PATH"));
assert!(!overrides.contains_key("HOME"));
assert!(!overrides.contains_key("XDG_CONFIG_HOME"));
assert_eq!(
overrides.get("OPENAI_API_KEY").map(String::as_str),
Some("")
);
assert_eq!(
overrides.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
Some("")
);
assert_eq!(overrides.get("HTTPS_PROXY").map(String::as_str), Some(""));
assert_eq!(
overrides.get("GALAXY_INTERNAL_SECRET").map(String::as_str),
Some("")
);
assert_eq!(
overrides.get("XDG_AGENT_TOKEN").map(String::as_str),
Some("")
);
assert_eq!(
overrides.get("LC_AGENT_TOKEN").map(String::as_str),
Some("")
);
assert_eq!(
overrides.get("INITIAL_AGENT_MODE").map(String::as_str),
Some("read-only")
);
assert_eq!(
overrides.get("DEFAULT_AUTH_REQUEST").map(String::as_str),
Some(r#"{"methodId":"chat-gpt"}"#)
);
}
#[test]
fn explicit_agent_environment_wins_over_scrubbing() {
let parent = [(
OsString::from("AGENT_AUTH_TOKEN"),
OsString::from("parent-secret"),
)];
let explicit = BTreeMap::from([("AGENT_AUTH_TOKEN".to_owned(), "explicit-value".to_owned())]);
let overrides = sanitized_environment_overrides(parent, &explicit);
assert_eq!(
overrides.get("AGENT_AUTH_TOKEN").map(String::as_str),
Some("explicit-value")
);
}
#[test]
fn configured_commands_are_resolved_before_launch() {
let executable = std::env::current_exe().unwrap();
let launch = AcpLaunchConfig::new(&executable).resolve_command().unwrap();
assert_eq!(launch.command, executable);
}
#[test]
fn executable_resolution_uses_platform_extensions_in_order() {
let temp_dir = tempfile::TempDir::new().unwrap();
let command_path = temp_dir.path().join("npx.cmd");
std::fs::write(&command_path, "@echo off\r\n").unwrap();
let resolved = find_executable_in_directories(
"npx",
[temp_dir.path().to_owned()],
&[".exe".to_owned(), ".cmd".to_owned()],
);
assert_eq!(resolved, Some(command_path));
}
#[test]
fn executable_resolution_does_not_append_extensions_to_explicit_extension() {
let temp_dir = tempfile::TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("agent.exe.cmd"), "@echo off\r\n").unwrap();
let resolved = find_executable_in_directories(
"agent.exe",
[temp_dir.path().to_owned()],
&[".cmd".to_owned()],
);
assert_eq!(resolved, None);
}
#[test]
fn windows_path_extensions_are_parsed_in_declared_order() {
let extensions = windows_executable_extensions(Some(std::ffi::OsStr::new(".COM;.EXE; .CMD;")));
assert_eq!(extensions, vec![".COM", ".EXE", ".CMD"]);
}
#[test]
fn windows_path_extensions_use_standard_fallback_when_missing_or_empty() {
let expected = vec![".COM", ".EXE", ".BAT", ".CMD"];
assert_eq!(windows_executable_extensions(None), expected);
assert_eq!(
windows_executable_extensions(Some(std::ffi::OsStr::new(" ; "))),
expected
);
}
#[test]
fn codex_path_sets_the_adapter_environment_variable() {
let launch = AcpLaunchConfig::new("npx").codex_path(Path::new("/opt/codex"));
assert_eq!(
launch.env.get("CODEX_PATH").map(String::as_str),
Some("/opt/codex")
);
}
#[test]
fn initialization_timeout_is_configurable_and_bounded_by_default() {
let default = AcpManagerConfig::new(AcpLaunchConfig::new("agent"));
let custom = AcpManagerConfig::new(AcpLaunchConfig::new("agent"))
.initialization_timeout(Duration::from_secs(2))
.authentication_timeout(Duration::from_secs(3));
assert_eq!(default.initialization_timeout, Duration::from_secs(30));
assert_eq!(default.authentication_timeout, Duration::from_secs(5 * 60));
assert_eq!(custom.initialization_timeout, Duration::from_secs(2));
assert_eq!(custom.authentication_timeout, Duration::from_secs(3));
}
+103
View File
@@ -0,0 +1,103 @@
use agent_client_protocol::schema::v1::{
ContentBlock, Cost, RequestPermissionRequest, SessionId, StopReason, ToolCallId, ToolCallStatus,
};
use crate::PermissionDecision;
/// Visible events produced while Galaxy drives an ACP turn.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum AcpEvent {
/// A new or restored ACP session is ready.
SessionStarted {
/// Agent-owned session identifier.
session_id: SessionId,
/// Whether this agent advertised `session/load`.
can_load: bool,
/// Whether this agent advertised the Codex `_session/steering`
/// extension.
can_steer: bool,
},
/// A streamed text fragment from the agent.
AgentText {
/// Markdown-capable text fragment.
text: String,
},
/// A streamed reasoning fragment from the agent.
AgentThought {
/// Thought text fragment.
text: String,
},
/// A non-text content block from an agent message or thought.
AgentContent {
/// ACP content block.
content: ContentBlock,
/// `true` when this block came from an agent-thought update.
thought: bool,
},
/// Content supplied by the user while a prompt is already running.
///
/// ACP agents use this update to echo accepted live steering input. Galaxy
/// can render it as a user message without exposing hidden system context.
UserContent {
/// ACP content block accepted by the agent.
content: ContentBlock,
},
/// A tool call began.
ToolCall {
/// ACP tool-call identifier.
id: ToolCallId,
/// Human-readable title.
title: String,
/// Current execution status.
status: ToolCallStatus,
/// Bounded, control-sequence-free output suitable for Galaxy's tool pane.
output: Option<String>,
},
/// A tool call changed.
ToolCallUpdate {
/// ACP tool-call identifier.
id: ToolCallId,
/// New title, when supplied by the agent.
title: Option<String>,
/// New status, when supplied by the agent.
status: Option<ToolCallStatus>,
/// Bounded, control-sequence-free output supplied by this update.
output: Option<String>,
},
/// Context-window or cost information changed.
Usage {
/// Tokens currently in context.
used: u64,
/// Total context-window size.
size: u64,
/// Optional cumulative cost.
cost: Option<Cost>,
},
/// The agent asked for permission.
PermissionRequested {
/// Original request, suitable for rendering in Galaxy.
request: RequestPermissionRequest,
},
/// Galaxy's permission hook resolved a request.
PermissionResolved {
/// Session to which the permission applies.
session_id: SessionId,
/// Decision returned by the hook.
decision: PermissionDecision,
},
/// The prompt turn completed.
Finished {
/// ACP stop reason.
stop_reason: StopReason,
},
/// A recoverable or terminal runtime error for this turn.
Error {
/// Safe, user-presentable error description.
message: String,
},
}
#[cfg(test)]
#[path = "events_tests.rs"]
mod tests;
+65
View File
@@ -0,0 +1,65 @@
use agent_client_protocol::schema::v1::{ContentBlock, SessionId, StopReason, TextContent};
use super::*;
#[test]
fn session_started_exposes_agent_capabilities() {
let event = AcpEvent::SessionStarted {
session_id: SessionId::new("session-1"),
can_load: true,
can_steer: false,
};
assert_eq!(
event,
AcpEvent::SessionStarted {
session_id: SessionId::new("session-1"),
can_load: true,
can_steer: false,
}
);
}
#[test]
fn finished_preserves_the_protocol_stop_reason() {
let event = AcpEvent::Finished {
stop_reason: StopReason::Cancelled,
};
assert_eq!(
event,
AcpEvent::Finished {
stop_reason: StopReason::Cancelled,
}
);
}
#[test]
fn user_content_preserves_live_steering_input() {
let content = ContentBlock::Text(TextContent::new("stop at 75 seconds"));
let event = AcpEvent::UserContent {
content: content.clone(),
};
assert_eq!(event, AcpEvent::UserContent { content });
}
#[test]
fn tool_events_preserve_display_safe_output() {
let event = AcpEvent::ToolCall {
id: "tool-1".into(),
title: "Run tests".to_owned(),
status: agent_client_protocol::schema::v1::ToolCallStatus::Completed,
output: Some("42 tests passed".to_owned()),
};
assert_eq!(
event,
AcpEvent::ToolCall {
id: "tool-1".into(),
title: "Run tests".to_owned(),
status: agent_client_protocol::schema::v1::ToolCallStatus::Completed,
output: Some("42 tests passed".to_owned()),
}
);
}
+26
View File
@@ -0,0 +1,26 @@
//! Runtime support for driving ACP agents from Galaxy.
//!
//! This crate deliberately contains no Galaxy UI or application-model code. It
//! owns the ACP subprocess and translates the stable ACP v1 stream into a small
//! event surface that the app can consume.
mod config;
mod events;
mod permissions;
mod runtime;
pub use agent_client_protocol::schema::v1::{
ContentBlock, Cost, ImageContent, McpServer, McpServerHttp, McpServerStdio, PermissionOptionId,
SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
};
pub use config::{
AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION,
};
pub use events::AcpEvent;
pub use permissions::{
AcpPermissionPolicy, DenyByDefaultPermissionHandler, PermissionContext, PermissionDecision,
PermissionHandler,
};
pub use runtime::{
AcpRuntimeError, AcpSessionHandle, AcpSessionManager, AcpSteeringOutcome, AcpTurnRequest,
};
+133
View File
@@ -0,0 +1,133 @@
use std::sync::Arc;
use agent_client_protocol::schema::v1::{
PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
SelectedPermissionOutcome, ToolKind,
};
use futures::future::{BoxFuture, FutureExt as _};
/// Galaxy permissions that may be granted to an ACP agent for one turn.
///
/// This deliberately grants only categories marked `AlwaysAllow` by the
/// active Galaxy execution profile. Interactive permissions remain denied
/// until Galaxy can surface the agent's permission choices in its own UI.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AcpPermissionPolicy {
/// Permit file and data reads, including searches.
pub read: bool,
/// Permit edits, deletes, and moves.
pub edit: bool,
/// Permit command and code execution.
pub execute: bool,
/// Permit fetching external data.
pub fetch: bool,
/// Permit uncategorized tools, including MCP tools.
pub other: bool,
}
impl AcpPermissionPolicy {
fn allows(self, kind: ToolKind) -> bool {
match kind {
ToolKind::Read | ToolKind::Search => self.read,
ToolKind::Edit | ToolKind::Delete | ToolKind::Move => self.edit,
ToolKind::Execute => self.execute,
ToolKind::Think => true,
ToolKind::Fetch => self.fetch,
ToolKind::SwitchMode => false,
ToolKind::Other => self.other,
_ => false,
}
}
}
/// Information supplied to Galaxy's permission hook.
#[derive(Clone, Debug)]
pub struct PermissionContext {
/// The original ACP permission request.
pub request: RequestPermissionRequest,
/// Whether this turn was explicitly launched in Galaxy's autonomous
/// execution mode.
pub auto_approve: bool,
/// Category permissions inherited from the active Galaxy profile.
pub policy: AcpPermissionPolicy,
}
/// A decision returned by a [`PermissionHandler`].
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PermissionDecision {
/// Select the first one-shot (then persistent) allow option.
Allow,
/// Select the first one-shot (then persistent) reject option.
Deny,
/// Select a specific option advertised by the agent.
Select(PermissionOptionId),
/// Report that the permission interaction was cancelled.
Cancel,
}
/// Host hook used to resolve ACP permission requests.
pub trait PermissionHandler: Send + Sync {
/// Returns a permission decision without blocking the ACP dispatch loop.
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision>;
}
/// Safe default permission hook.
///
/// Requests are rejected unless the individual turn explicitly opts into
/// automatic approval.
#[derive(Debug, Default)]
pub struct DenyByDefaultPermissionHandler;
impl PermissionHandler for DenyByDefaultPermissionHandler {
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision> {
async move {
let kind = context.request.tool_call.fields.kind.unwrap_or_default();
if context.auto_approve || context.policy.allows(kind) {
PermissionDecision::Allow
} else {
PermissionDecision::Deny
}
}
.boxed()
}
}
pub(crate) fn outcome_for_decision(
request: &RequestPermissionRequest,
decision: PermissionDecision,
) -> RequestPermissionOutcome {
let selected = match decision {
PermissionDecision::Allow => request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowOnce)
.map(|option| option.option_id.clone()),
PermissionDecision::Deny => request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::RejectOnce)
.or_else(|| {
request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::RejectAlways)
})
.map(|option| option.option_id.clone()),
PermissionDecision::Select(option_id) => request
.options
.iter()
.find(|option| option.option_id == option_id)
.map(|option| option.option_id.clone()),
PermissionDecision::Cancel => None,
};
selected.map_or(RequestPermissionOutcome::Cancelled, |option_id| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id))
})
}
pub(crate) type SharedPermissionHandler = Arc<dyn PermissionHandler>;
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;
+126
View File
@@ -0,0 +1,126 @@
use agent_client_protocol::schema::v1::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
ToolCallUpdate, ToolCallUpdateFields, ToolKind,
};
use super::*;
fn permission_request() -> RequestPermissionRequest {
permission_request_for(ToolKind::Other)
}
fn permission_request_for(kind: ToolKind) -> RequestPermissionRequest {
RequestPermissionRequest::new(
"session-1",
ToolCallUpdate::new(
"tool-1",
ToolCallUpdateFields::new().title("Run command").kind(kind),
),
vec![
PermissionOption::new(
"allow-always",
"Always allow",
PermissionOptionKind::AllowAlways,
),
PermissionOption::new("allow-once", "Allow once", PermissionOptionKind::AllowOnce),
PermissionOption::new(
"reject-always",
"Always reject",
PermissionOptionKind::RejectAlways,
),
PermissionOption::new(
"reject-once",
"Reject once",
PermissionOptionKind::RejectOnce,
),
],
)
}
#[test]
fn default_handler_denies_interactive_turns() {
let request = permission_request();
let decision =
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
request,
auto_approve: false,
policy: AcpPermissionPolicy::default(),
}));
assert_eq!(decision, PermissionDecision::Deny);
}
#[test]
fn default_handler_allows_only_explicit_autonomous_turns() {
let request = permission_request();
let decision =
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
request,
auto_approve: true,
policy: AcpPermissionPolicy::default(),
}));
assert_eq!(decision, PermissionDecision::Allow);
}
#[test]
fn default_handler_honors_only_the_matching_profile_category() {
let policy = AcpPermissionPolicy {
read: true,
..Default::default()
};
let read =
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
request: permission_request_for(ToolKind::Read),
auto_approve: false,
policy,
}));
let execute =
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
request: permission_request_for(ToolKind::Execute),
auto_approve: false,
policy,
}));
assert_eq!(read, PermissionDecision::Allow);
assert_eq!(execute, PermissionDecision::Deny);
}
#[test]
fn allow_and_deny_prefer_one_shot_options() {
let request = permission_request();
let allow = outcome_for_decision(&request, PermissionDecision::Allow);
let deny = outcome_for_decision(&request, PermissionDecision::Deny);
assert_eq!(
allow,
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("allow-once"))
);
assert_eq!(
deny,
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("reject-once"))
);
}
#[test]
fn allow_never_silently_selects_a_persistent_option() {
let mut request = permission_request();
request
.options
.retain(|option| option.kind != PermissionOptionKind::AllowOnce);
let outcome = outcome_for_decision(&request, PermissionDecision::Allow);
assert_eq!(outcome, RequestPermissionOutcome::Cancelled);
}
#[test]
fn unknown_selected_option_is_cancelled() {
let outcome = outcome_for_decision(
&permission_request(),
PermissionDecision::Select(PermissionOptionId::new("not-advertised")),
);
assert_eq!(outcome, RequestPermissionOutcome::Cancelled);
}
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, InitializeResponse,
McpServer, McpServerStdio, SessionId, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
};
use agent_client_protocol::schema::ProtocolVersion;
use super::*;
fn pending_turn(turn_id: u64, cwd: &str, session_id: Option<&str>) -> PendingTurn {
let mut request = AcpTurnRequest::text("conversation", PathBuf::from(cwd), "hello");
request.session_id = session_id.map(SessionId::new);
let (events, _receiver) = async_channel::unbounded();
PendingTurn {
turn_id,
request,
events,
}
}
fn advertised_auth_method(id: &'static str) -> AuthMethod {
AuthMethod::Agent(AuthMethodAgent::new(id, id))
}
#[test]
fn authentication_is_skipped_when_agent_advertises_no_methods() {
let request = authentication_request(&[], None).unwrap();
assert_eq!(request, None);
}
#[test]
fn authentication_uses_first_advertised_method_by_default() {
let methods = [
advertised_auth_method("recommended"),
advertised_auth_method("alternative"),
];
let request = authentication_request(&methods, None).unwrap().unwrap();
assert_eq!(request.method_id, AuthMethodId::new("recommended"));
}
#[test]
fn authentication_uses_explicit_preference_instead_of_advertised_order() {
let methods = [
advertised_auth_method("api-key"),
advertised_auth_method("chat-gpt"),
];
let request = authentication_request(&methods, Some(&AuthMethodId::new("chat-gpt")))
.unwrap()
.unwrap();
assert_eq!(request.method_id, AuthMethodId::new("chat-gpt"));
}
#[test]
fn authentication_rejects_preference_that_agent_did_not_advertise() {
let methods = [
advertised_auth_method("api-key"),
advertised_auth_method("chat-gpt"),
];
let error = authentication_request(&methods, Some(&AuthMethodId::new("missing"))).unwrap_err();
assert!(error
.to_string()
.contains("preferred authentication method"));
assert!(error.to_string().contains("api-key, chat-gpt"));
}
#[test]
fn turn_validation_requires_absolute_paths() {
let request = AcpTurnRequest::text("conversation", "relative", "hello");
assert!(matches!(
request.validate(),
Err(AcpRuntimeError::InvalidTurn(_))
));
}
#[test]
fn turn_validation_rejects_relative_mcp_commands() {
let request = AcpTurnRequest::text("conversation", PathBuf::from("/workspace"), "hello")
.mcp_servers(vec![McpServer::Stdio(McpServerStdio::new(
"server",
"relative-command",
))]);
assert!(matches!(
request.validate(),
Err(AcpRuntimeError::InvalidTurn(_))
));
}
#[test]
fn persisted_session_restore_is_rejected_when_not_advertised() {
let requested = SessionId::new("persisted");
assert_eq!(
restorable_session_id(Some(&requested), true),
Ok(Some(requested.clone()))
);
let error = restorable_session_id(Some(&requested), false).unwrap_err();
assert!(error
.to_string()
.contains("does not advertise session/load"));
assert_eq!(restorable_session_id(None, false), Ok(None));
}
#[test]
fn successful_session_load_does_not_create_a_replacement() {
let load_calls = Arc::new(AtomicUsize::new(0));
let create_calls = Arc::new(AtomicUsize::new(0));
let load_count = Arc::clone(&load_calls);
let create_count = Arc::clone(&create_calls);
let result = futures::executor::block_on(open_session(
Some(SessionId::new("persisted")),
move |_| {
load_count.fetch_add(1, Ordering::Relaxed);
futures::future::ready(Ok(()))
},
move || {
create_count.fetch_add(1, Ordering::Relaxed);
futures::future::ready(Ok(SessionId::new("replacement")))
},
))
.unwrap();
assert_eq!(result, SessionId::new("persisted"));
assert_eq!(load_calls.load(Ordering::Relaxed), 1);
assert_eq!(create_calls.load(Ordering::Relaxed), 0);
}
#[test]
fn failed_session_load_is_visible_and_does_not_create_a_replacement() {
let load_calls = Arc::new(AtomicUsize::new(0));
let create_calls = Arc::new(AtomicUsize::new(0));
let load_count = Arc::clone(&load_calls);
let create_count = Arc::clone(&create_calls);
let error = futures::executor::block_on(open_session(
Some(SessionId::new("expired")),
move |_| {
load_count.fetch_add(1, Ordering::Relaxed);
futures::future::ready(Err(agent_client_protocol::Error::new(
-32000,
"unknown session",
)))
},
move || {
create_count.fetch_add(1, Ordering::Relaxed);
futures::future::ready(Ok(SessionId::new("fresh")))
},
))
.unwrap_err();
assert!(error.to_string().contains("unknown session"));
assert_eq!(load_calls.load(Ordering::Relaxed), 1);
assert_eq!(create_calls.load(Ordering::Relaxed), 0);
}
#[test]
fn new_session_failure_is_returned_when_no_persisted_session_exists() {
let error = futures::executor::block_on(open_session(
None,
|_| futures::future::ready(Ok(())),
|| {
futures::future::ready(Err(agent_client_protocol::Error::new(
-32001,
"new session failed",
)))
},
))
.unwrap_err();
assert!(error.to_string().contains("new session failed"));
}
#[test]
fn steering_support_uses_codex_extension_metadata() {
let mut steering = serde_json::Map::new();
steering.insert("supported".to_owned(), serde_json::Value::Bool(true));
let mut meta = serde_json::Map::new();
meta.insert("steering".to_owned(), serde_json::Value::Object(steering));
let response = InitializeResponse::new(ProtocolVersion::V1).meta(meta);
assert!(supports_steering(&response));
assert!(!supports_steering(&InitializeResponse::new(
ProtocolVersion::V1
)));
}
#[test]
fn streamed_text_and_thoughts_are_visible_events() {
let text = event_from_session_update(SessionUpdate::AgentMessageChunk(ContentChunk::new(
ContentBlock::Text(TextContent::new("answer")),
)));
let thought = event_from_session_update(SessionUpdate::AgentThoughtChunk(ContentChunk::new(
ContentBlock::Text(TextContent::new("reasoning")),
)));
assert_eq!(
text,
Some(AcpEvent::AgentText {
text: "answer".to_owned()
})
);
assert_eq!(
thought,
Some(AcpEvent::AgentThought {
text: "reasoning".to_owned()
})
);
}
#[test]
fn streamed_user_content_is_a_visible_steering_event() {
let content = ContentBlock::Text(TextContent::new("stop after this step"));
let event = event_from_session_update(SessionUpdate::UserMessageChunk(ContentChunk::new(
content.clone(),
)));
assert_eq!(event, Some(AcpEvent::UserContent { content }));
}
#[test]
fn tool_and_usage_updates_are_visible_events() {
let tool = event_from_session_update(SessionUpdate::ToolCall(
ToolCall::new("tool-1", "Run tests").status(ToolCallStatus::InProgress),
));
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
"tool-1",
ToolCallUpdateFields::new()
.title("Tests passed")
.status(ToolCallStatus::Completed),
)));
let usage =
event_from_session_update(SessionUpdate::UsageUpdate(UsageUpdate::new(400, 200_000)));
assert_eq!(
tool,
Some(AcpEvent::ToolCall {
id: "tool-1".into(),
title: "Run tests".to_owned(),
status: ToolCallStatus::InProgress,
output: None,
})
);
assert_eq!(
update,
Some(AcpEvent::ToolCallUpdate {
id: "tool-1".into(),
title: Some("Tests passed".to_owned()),
status: Some(ToolCallStatus::Completed),
output: None,
})
);
assert_eq!(
usage,
Some(AcpEvent::Usage {
used: 400,
size: 200_000,
cost: None,
})
);
}
#[test]
fn tool_content_is_sanitized_before_becoming_visible_output() {
let tool = event_from_session_update(SessionUpdate::ToolCall(
ToolCall::new("tool-1", "Run tests").content(vec![ToolCallContent::from(
ContentBlock::Text(TextContent::new(
"\u{1b}[31m42 tests passed\u{1b}[0m\0\u{202e}",
)),
)]),
));
assert_eq!(
tool,
Some(AcpEvent::ToolCall {
id: "tool-1".into(),
title: "Run tests".to_owned(),
status: ToolCallStatus::Pending,
output: Some("42 tests passed".to_owned()),
})
);
}
#[test]
fn raw_tool_output_uses_display_text_and_agent_truncation_metadata() {
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
"tool-1",
ToolCallUpdateFields::new().raw_output(serde_json::json!({
"output": "first lines",
"metadata": {
"truncated": true
}
})),
)));
assert_eq!(
update,
Some(AcpEvent::ToolCallUpdate {
id: "tool-1".into(),
title: None,
status: None,
output: Some("first lines\n[output truncated by ACP agent]".to_owned()),
})
);
}
#[test]
fn terminal_output_extension_metadata_becomes_visible_output() {
let meta: Meta = serde_json::from_value(serde_json::json!({
"terminal_output": {
"data": "\u{1b}[32mApplying migrations\u{1b}[0m\n",
"terminal_id": "terminal-1"
}
}))
.unwrap();
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(
ToolCallUpdate::new("tool-1", ToolCallUpdateFields::new()).meta(meta),
));
assert_eq!(
update,
Some(AcpEvent::ToolCallUpdate {
id: "tool-1".into(),
title: None,
status: None,
output: Some("Applying migrations\n".to_owned()),
})
);
}
#[test]
fn terminal_exit_metadata_avoids_replaying_aggregated_raw_output() {
let meta: Meta = serde_json::from_value(serde_json::json!({
"terminal_exit": {
"exit_code": 0,
"signal": null,
"terminal_id": "terminal-1"
}
}))
.unwrap();
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(
ToolCallUpdate::new(
"tool-1",
ToolCallUpdateFields::new()
.status(ToolCallStatus::Completed)
.raw_output(serde_json::json!({
"formatted_output": "already streamed",
"exit_code": 0
})),
)
.meta(meta),
));
assert_eq!(
update,
Some(AcpEvent::ToolCallUpdate {
id: "tool-1".into(),
title: None,
status: Some(ToolCallStatus::Completed),
output: Some("[terminal exited: code 0]".to_owned()),
})
);
}
#[test]
fn visible_tool_output_is_utf8_safe_and_bounded() {
let long_output = "🚀".repeat(MAX_VISIBLE_TOOL_OUTPUT_BYTES);
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
"tool-1",
ToolCallUpdateFields::new().content(vec![ToolCallContent::from(ContentBlock::Text(
TextContent::new(long_output),
))]),
)));
let Some(AcpEvent::ToolCallUpdate {
output: Some(output),
..
}) = update
else {
panic!("expected a visible tool-call update");
};
assert!(output.is_char_boundary(output.len()));
assert!(output.len() <= MAX_VISIBLE_TOOL_OUTPUT_BYTES);
assert!(output.ends_with(TOOL_OUTPUT_TRUNCATION_MARKER));
}
#[test]
fn load_replay_is_suppressed_until_the_session_is_ready() {
let router = EventRouter::default();
let session_id = SessionId::new("persisted");
let (events, receiver) = async_channel::unbounded();
router.set_route(
session_id.clone(),
EventRoute {
turn_id: 1,
events,
auto_approve: false,
permission_policy: AcpPermissionPolicy::default(),
},
);
router.suppress_replay(session_id.clone());
router.on_session_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(
"old answer",
)))),
));
assert!(matches!(
receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
router.finish_replay(&session_id);
router.on_session_notification(SessionNotification::new(
session_id,
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(
"new answer",
)))),
));
assert_eq!(
receiver.try_recv(),
Ok(AcpEvent::AgentText {
text: "new answer".to_owned(),
})
);
}
#[test]
fn queued_spec_changes_rotate_sessions_without_losing_fifo_order() {
let first = pending_turn(1, "/workspace/first", None);
let mut state = ConversationState::new(first);
state.ready = true;
state.session_id = Some(SessionId::new("old-session"));
let mut second = pending_turn(2, "/workspace/second", Some("old-session"));
second.request.mcp_servers = vec![McpServer::Stdio(McpServerStdio::new(
"galaxy",
"/usr/bin/galaxy",
))];
let mut third = pending_turn(3, "/workspace/second", Some("old-session"));
third.request.mcp_servers = second.request.mcp_servers.clone();
state.queued.push_back(second);
state.queued.push_back(third);
state.active.take();
assert!(state.activate_next());
assert_eq!(state.session_id, None);
assert!(!state.ready);
let second = state.active.as_ref().unwrap();
assert_eq!(second.pending.turn_id, 2);
assert_eq!(second.pending.request.session_id, None);
assert_eq!(second.phase, TurnPhase::Opening);
state.ready = true;
state.session_id = Some(SessionId::new("new-session"));
state.active.take();
assert!(state.activate_next());
assert_eq!(state.session_id, Some(SessionId::new("new-session")));
let third = state.active.as_ref().unwrap();
assert_eq!(third.pending.turn_id, 3);
assert_eq!(third.phase, TurnPhase::Prompting);
assert!(state.queued.is_empty());
}
#[test]
fn terminal_actor_errors_are_visible_to_active_and_queued_turns() {
let (active_events, active_receiver) = async_channel::unbounded();
let mut active = pending_turn(1, "/workspace", None);
active.events = active_events;
let (queued_events, queued_receiver) = async_channel::unbounded();
let mut queued = pending_turn(2, "/workspace", None);
queued.events = queued_events;
let mut state = ConversationState::new(active);
state.queued.push_back(queued);
let conversations = HashMap::from([("conversation".to_owned(), state)]);
fail_conversations(&conversations, "protocol dispatch failed");
assert_eq!(
active_receiver.try_recv(),
Ok(AcpEvent::Error {
message: "protocol dispatch failed".to_owned(),
})
);
assert_eq!(
queued_receiver.try_recv(),
Ok(AcpEvent::Error {
message: "protocol dispatch failed".to_owned(),
})
);
}
#[test]
fn steering_outcome_uses_the_codex_wire_values() {
assert_eq!(
serde_json::to_value(AcpSteeringOutcome::Injected).unwrap(),
serde_json::json!("injected")
);
assert_eq!(
serde_json::from_value::<AcpSteeringOutcome>(serde_json::json!("startedNewTurn")).unwrap(),
AcpSteeringOutcome::StartedNewTurn
);
}
#[test]
fn implicit_steering_turn_requires_immediate_teardown() {
assert!(!steering_became_untracked(AcpSteeringOutcome::Injected));
assert!(steering_became_untracked(
AcpSteeringOutcome::StartedNewTurn
));
assert!(!steering_became_untracked(AcpSteeringOutcome::Failed));
}
#[test]
fn untracked_steering_command_preserves_the_result_acknowledgement() {
let (ack, acknowledgement) = oneshot::channel();
let command = Command::AbortUntrackedSteering {
conversation_key: "conversation-1".to_owned(),
turn_id: 42,
result: Ok(AcpSteeringOutcome::StartedNewTurn),
ack,
};
let Command::AbortUntrackedSteering { result, ack, .. } = command else {
panic!("expected an immediate untracked-steering abort");
};
let _ = ack.send(result);
assert_eq!(
futures::executor::block_on(acknowledgement)
.unwrap()
.unwrap(),
AcpSteeringOutcome::StartedNewTurn
);
}
struct PendingConnection {
dropped: Arc<AtomicBool>,
}
impl Future for PendingConnection {
type Output = Result<(), AcpRuntimeError>;
fn poll(self: Pin<&mut Self>, _ctx: &mut Context<'_>) -> Poll<Self::Output> {
Poll::Pending
}
}
impl Drop for PendingConnection {
fn drop(&mut self) {
self.dropped.store(true, Ordering::Release);
}
}
#[test]
fn initialization_timeout_cancels_the_connection_future() {
let dropped = Arc::new(AtomicBool::new(false));
let (_initialized_tx, initialized_rx) = oneshot::channel();
let (_authenticated_tx, authenticated_rx) = oneshot::channel();
let result = futures::executor::block_on(supervise_connection_readiness(
PendingConnection {
dropped: Arc::clone(&dropped),
},
initialized_rx,
authenticated_rx,
Duration::from_millis(10),
Duration::from_secs(1),
));
assert!(matches!(
result,
Err(AcpRuntimeError::InitializationTimeout(_))
));
assert!(dropped.load(Ordering::Acquire));
}
#[test]
fn authentication_timeout_cancels_the_initialized_connection_future() {
let dropped = Arc::new(AtomicBool::new(false));
let (initialized_tx, initialized_rx) = oneshot::channel();
let (_authenticated_tx, authenticated_rx) = oneshot::channel();
let _ = initialized_tx.send(());
let result = futures::executor::block_on(supervise_connection_readiness(
PendingConnection {
dropped: Arc::clone(&dropped),
},
initialized_rx,
authenticated_rx,
Duration::from_secs(1),
Duration::from_millis(10),
));
assert!(matches!(
result,
Err(AcpRuntimeError::AuthenticationTimeout(_))
));
assert!(dropped.load(Ordering::Acquire));
}
#[cfg(unix)]
#[test]
fn hung_agent_initialization_closes_queued_turns_and_cancellation() {
let manager = AcpSessionManager::spawn(
AcpManagerConfig::new(AcpLaunchConfig::new("/bin/sh").args(["-c", "exec sleep 30"]))
.initialization_timeout(Duration::from_millis(50)),
)
.unwrap();
let (handle, events) = manager
.run_turn(AcpTurnRequest::text(
"conversation",
PathBuf::from("/workspace"),
"hello",
))
.unwrap();
let cancellation = futures::executor::block_on(async {
match future::select(
Box::pin(handle.cancel()),
Box::pin(async_io::Timer::after(Duration::from_secs(2))),
)
.await
{
Either::Left((result, _)) => result,
Either::Right((_, _)) => panic!("cancellation remained blocked after init timeout"),
}
});
let event = futures::executor::block_on(async {
match future::select(
Box::pin(events.recv()),
Box::pin(async_io::Timer::after(Duration::from_secs(2))),
)
.await
{
Either::Left((result, _)) => result.unwrap(),
Either::Right((_, _)) => panic!("queued turn was not failed after init timeout"),
}
});
assert!(matches!(
cancellation,
Err(AcpRuntimeError::RuntimeClosed(_))
));
assert!(matches!(
event,
AcpEvent::Error { message } if message.contains("did not initialize")
));
assert!(!manager.is_alive());
}
#[test]
fn manager_liveness_is_observable() {
let (command_tx, _command_rx) = async_channel::unbounded();
let manager = AcpSessionManager {
inner: Arc::new(ManagerInner {
command_tx,
launch: AcpLaunchConfig::new("agent"),
alive: AtomicBool::new(true),
terminal_error: Mutex::new(None),
}),
};
assert!(manager.is_alive());
manager.inner.alive.store(false, Ordering::Release);
assert!(!manager.is_alive());
}
#[test]
fn process_tree_teardown_allows_supported_platforms() {
assert!(validate_process_tree_teardown(true).is_ok());
}
#[test]
fn process_tree_teardown_fails_closed_on_unsupported_platforms() {
assert!(matches!(
validate_process_tree_teardown(false),
Err(AcpRuntimeError::ProcessTreeTeardownUnsupported)
));
}
#[test]
fn dropping_the_last_manager_requests_worker_shutdown() {
let (command_tx, command_rx) = async_channel::unbounded();
let manager = AcpSessionManager {
inner: Arc::new(ManagerInner {
command_tx,
launch: AcpLaunchConfig::new("agent"),
alive: AtomicBool::new(true),
terminal_error: Mutex::new(None),
}),
};
drop(manager);
assert!(matches!(command_rx.try_recv(), Ok(Command::Shutdown)));
}
#[test]
fn session_handle_cancel_targets_its_exact_turn_and_waits_for_ack() {
let (command_tx, command_rx) = async_channel::unbounded();
let manager = AcpSessionManager {
inner: Arc::new(ManagerInner {
command_tx,
launch: AcpLaunchConfig::new("agent"),
alive: AtomicBool::new(true),
terminal_error: Mutex::new(None),
}),
};
let handle = AcpSessionHandle {
manager,
conversation_key: "conversation-1".to_owned(),
turn_id: 42,
};
let acknowledge = async move {
let command = command_rx.recv().await.unwrap();
let Command::Cancel {
conversation_key,
turn_id,
ack,
} = command
else {
panic!("cancel must not be translated into another command");
};
assert_eq!(conversation_key, "conversation-1");
assert_eq!(turn_id, 42);
let _ = ack.send(Ok(()));
};
let (result, ()) =
futures::executor::block_on(futures::future::join(handle.cancel(), acknowledge));
assert!(result.is_ok());
}
#[test]
fn steering_uses_its_typed_command_and_preserves_unsupported_error() {
let (command_tx, command_rx) = async_channel::unbounded();
let manager = AcpSessionManager {
inner: Arc::new(ManagerInner {
command_tx,
launch: AcpLaunchConfig::new("agent"),
alive: AtomicBool::new(true),
terminal_error: Mutex::new(None),
}),
};
let handle = AcpSessionHandle {
manager,
conversation_key: "conversation-1".to_owned(),
turn_id: 7,
};
let prompt = vec![ContentBlock::Text(TextContent::new("stop after this step"))];
let respond = async move {
let command = command_rx.recv().await.unwrap();
let Command::Steer {
conversation_key,
turn_id,
prompt,
ack,
} = command
else {
panic!("steering must never fall back to a concurrent prompt");
};
assert_eq!(conversation_key, "conversation-1");
assert_eq!(turn_id, 7);
assert_eq!(
prompt,
vec![ContentBlock::Text(TextContent::new("stop after this step"))]
);
let _ = ack.send(Err(AcpRuntimeError::SteeringUnsupported));
};
let (result, ()) =
futures::executor::block_on(futures::future::join(handle.steer(prompt), respond));
assert!(matches!(result, Err(AcpRuntimeError::SteeringUnsupported)));
}