This commit is contained in:
2026-08-05 00:56:55 -05:00
parent b0ad07f6f2
commit c321e17708
44 changed files with 2005 additions and 598 deletions
+4
View File
@@ -7,8 +7,12 @@ publish = false
[dependencies]
agent-client-protocol.workspace = true
async-channel.workspace = true
async-stream.workspace = true
async-trait.workspace = true
base64.workspace = true
async-io.workspace = true
futures.workspace = true
galaxy_agent_core.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
+486
View File
@@ -0,0 +1,486 @@
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64::Engine as _;
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
use futures::stream::FusedStream as _;
use futures::{FutureExt as _, StreamExt as _};
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ContentPart,
MessageContent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, RuntimeDescriptor,
RuntimeKind, StopReason as AgentStopReason, TurnCommand, TurnControl, TurnRequest,
};
use crate::{
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, ImageContent, McpServer, SessionConfigOption,
SessionConfigOptionValue, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
};
/// Session state learned from the connected ACP agent.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AcpRuntimeState {
pub session_id: Option<String>,
pub can_load: bool,
pub can_steer: bool,
pub config_options: Vec<SessionConfigOption>,
}
pub type AcpRuntimeStateHandle = Arc<Mutex<AcpRuntimeState>>;
/// ACP-specific session inputs that are stable for one Galaxy turn.
#[derive(Clone, Debug)]
pub struct AcpAgentRuntimeConfig {
pub runtime_id: String,
pub display_name: String,
pub config_values: BTreeMap<String, SessionConfigOptionValue>,
pub session_id: Option<SessionId>,
pub cwd: PathBuf,
pub additional_directories: Vec<PathBuf>,
pub mcp_servers: Vec<McpServer>,
pub auto_approve_permissions: bool,
pub permission_policy: AcpPermissionPolicy,
}
impl AcpAgentRuntimeConfig {
#[must_use]
pub fn new(
runtime_id: impl Into<String>,
display_name: impl Into<String>,
cwd: impl Into<PathBuf>,
) -> Self {
Self {
runtime_id: runtime_id.into(),
display_name: display_name.into(),
config_values: BTreeMap::new(),
session_id: None,
cwd: cwd.into(),
additional_directories: Vec::new(),
mcp_servers: Vec::new(),
auto_approve_permissions: false,
permission_policy: AcpPermissionPolicy::default(),
}
}
}
/// Adapts an official ACP client session to Galaxy's provider-neutral runtime contract.
pub struct AcpAgentRuntime {
manager: AcpSessionManager,
config: AcpAgentRuntimeConfig,
descriptor: RuntimeDescriptor,
state: AcpRuntimeStateHandle,
}
impl AcpAgentRuntime {
#[must_use]
pub fn new(manager: AcpSessionManager, config: AcpAgentRuntimeConfig) -> Self {
let descriptor = RuntimeDescriptor {
id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
kind: RuntimeKind::Acp,
capabilities: RuntimeCapabilities {
model_selection: false,
session_resume: true,
steering: true,
tool_permissions: true,
},
};
Self {
manager,
config,
descriptor,
state: Arc::new(Mutex::new(AcpRuntimeState::default())),
}
}
#[must_use]
pub fn with_state(mut self, state: AcpRuntimeStateHandle) -> Self {
self.state = state;
self
}
#[must_use]
pub fn state(&self) -> AcpRuntimeStateHandle {
Arc::clone(&self.state)
}
fn acp_turn_request(&self, request: TurnRequest) -> Result<AcpTurnRequest, AgentError> {
if request.model.as_str() != self.descriptor.id {
return Err(invalid_request(format!(
"ACP runtime '{}' cannot serve selection '{}'",
self.descriptor.id,
request.model.as_str()
)));
}
if !request.messages.is_empty() {
return Err(invalid_request(
"ACP sessions own conversation history; only the explicit current prompt is accepted",
));
}
if request.max_output_tokens.is_some() {
return Err(invalid_request(
"ACP agents own their output limit; Galaxy cannot apply a provider token limit",
));
}
let conversation_key = request
.conversation_id
.ok_or_else(|| invalid_request("ACP turns require a stable Galaxy conversation ID"))?;
let prompt = request
.prompt
.ok_or_else(|| invalid_request("ACP turns require an explicit current prompt"))?;
if request.system_prompt.is_some() {
return Err(invalid_request(
"ACP agents own their system prompt; Galaxy cannot send a provider system message",
));
}
if !request.tools.is_empty() {
return Err(invalid_request(
"ACP tools must be attached as MCP servers, not provider tool definitions",
));
}
Ok(AcpTurnRequest {
config_values: self.config.config_values.clone(),
conversation_key,
session_id: self.config.session_id.clone(),
cwd: self.config.cwd.clone(),
additional_directories: self.config.additional_directories.clone(),
prompt: prompt_content(prompt)?,
mcp_servers: self.config.mcp_servers.clone(),
auto_approve_permissions: self.config.auto_approve_permissions,
permission_policy: self.config.permission_policy,
})
}
}
#[async_trait]
impl AgentRuntime for AcpAgentRuntime {
fn descriptor(&self) -> &RuntimeDescriptor {
&self.descriptor
}
async fn start_turn(
&self,
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let request = self.acp_turn_request(request)?;
let (session, events) = self
.manager
.run_turn(request)
.map_err(agent_error_from_runtime)?;
Ok(acp_event_stream(
session,
events,
control,
Arc::clone(&self.state),
))
}
}
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
fn pending_steering(session: AcpSessionHandle, model_text: String) -> PendingSteering {
async move {
session
.steer(vec![ContentBlock::Text(TextContent::new(model_text))])
.await
}
.boxed()
.fuse()
}
fn acp_event_stream(
session: AcpSessionHandle,
events: async_channel::Receiver<AcpEvent>,
control: TurnControl,
state: AcpRuntimeStateHandle,
) -> AgentEventStream {
let stream = async_stream::stream! {
let mut events = Box::pin(events.fuse());
let mut control_open = true;
let mut steering_queue = VecDeque::new();
let mut steering_result: PendingSteering = Fuse::terminated();
let mut tool_titles = HashMap::new();
loop {
let command = if control_open {
control.receive().boxed().fuse()
} else {
Fuse::terminated()
};
futures::pin_mut!(command);
futures::select_biased! {
command = command => match command {
Ok(TurnCommand::Cancel) => {
if let Err(error) = session.cancel().await {
log::warn!("Failed to cancel ACP turn cleanly: {error}");
}
yield Ok(AgentEvent::TurnStopped {
reason: AgentStopReason::Cancelled,
});
return;
}
Ok(TurnCommand::Steer { display_text, model_text }) => {
yield Ok(AgentEvent::UserInputAccepted { text: display_text });
if steering_result.is_terminated() {
steering_result = pending_steering(session.clone(), model_text);
} else {
steering_queue.push_back(model_text);
}
}
Err(_) => control_open = false,
},
steering = steering_result => {
match steering {
Ok(AcpSteeringOutcome::Injected) => {}
Ok(AcpSteeringOutcome::StartedNewTurn) => {
yield Ok(AgentEvent::RuntimeNotice {
message: "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.".to_owned(),
});
}
Ok(AcpSteeringOutcome::Failed) => {
yield Ok(AgentEvent::RuntimeNotice {
message: steering_failure_notice(
"the ACP agent could not inject it into the active turn",
),
});
}
Err(error) => {
log::warn!("ACP live steering failed: {error}");
yield Ok(AgentEvent::RuntimeNotice {
message: steering_failure_notice(&error.to_string()),
});
}
}
steering_result = Fuse::terminated();
if let Some(model_text) = steering_queue.pop_front() {
steering_result = pending_steering(session.clone(), model_text);
} else if events.is_terminated() {
yield Err(AgentError::new(
AgentErrorKind::Protocol,
"ACP event stream closed before the turn reported completion",
));
return;
}
},
event = events.next() => {
let Some(event) = event else {
if steering_result.is_terminated() && steering_queue.is_empty() {
yield Err(AgentError::new(
AgentErrorKind::Protocol,
"ACP event stream closed before the turn reported completion",
));
return;
}
continue;
};
match map_event(event, &state, &mut tool_titles) {
Ok(Some(event)) => {
let stopped = matches!(event, AgentEvent::TurnStopped { .. });
yield Ok(event);
if stopped {
return;
}
}
Ok(None) => {}
Err(error) => {
yield Err(error);
return;
}
}
}
}
}
};
Box::pin(stream)
}
fn map_event(
event: AcpEvent,
state: &AcpRuntimeStateHandle,
tool_titles: &mut HashMap<ToolCallId, String>,
) -> Result<Option<AgentEvent>, AgentError> {
let event = match event {
AcpEvent::SessionStarted {
session_id,
can_load,
can_steer,
..
} => {
if let Ok(mut state) = state.lock() {
state.session_id = Some(session_id.to_string());
state.can_load = can_load;
state.can_steer = can_steer;
}
Some(AgentEvent::TurnStarted {
runtime_request_id: session_id.to_string(),
})
}
AcpEvent::AgentText { text } => Some(AgentEvent::TextDelta { text }),
AcpEvent::AgentThought { text } => Some(AgentEvent::ReasoningDelta { text }),
AcpEvent::AgentContent { content, thought } => map_content(content, thought),
// Initial prompt echoes can contain hidden Galaxy context. Steering is
// emitted from the accepted command above, so no ACP echo is trusted.
AcpEvent::UserContent { .. } => None,
AcpEvent::ToolCall {
id,
title,
status,
output,
} => {
tool_titles.insert(id.clone(), title.clone());
Some(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: id.to_string(),
title,
status: Some(activity_status(status)),
output,
},
})
}
AcpEvent::ToolCallUpdate {
id,
title,
status,
output,
} => {
let title = title
.or_else(|| tool_titles.get(&id).cloned())
.unwrap_or_else(|| "tool".to_owned());
tool_titles.insert(id.clone(), title.clone());
Some(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: id.to_string(),
title,
status: status.map(activity_status),
output,
},
})
}
AcpEvent::ConfigOptions { options } => {
if let Ok(mut state) = state.lock() {
state.config_options = options;
}
None
}
AcpEvent::Usage { used, size, .. } => Some(AgentEvent::ContextUsageUpdated {
used_tokens: used,
context_size: size,
}),
AcpEvent::PermissionRequested { request } => Some(AgentEvent::RuntimeNotice {
message: format!(
"\n\n> Permission requested for: {}\n",
request.tool_call.fields.title.as_deref().unwrap_or("tool")
),
}),
AcpEvent::PermissionResolved { decision, .. } => Some(AgentEvent::RuntimeNotice {
message: format!("\n\n> Permission decision: {decision:?}\n"),
}),
AcpEvent::Finished { stop_reason } => Some(AgentEvent::TurnStopped {
reason: stop_reason_from_acp(stop_reason),
}),
AcpEvent::Error { message } => {
return Err(AgentError::new(AgentErrorKind::Protocol, message));
}
};
Ok(event)
}
fn map_content(content: ContentBlock, thought: bool) -> Option<AgentEvent> {
let text = 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(),
};
if thought {
Some(AgentEvent::ReasoningDelta { text })
} else {
Some(AgentEvent::TextDelta { text })
}
}
fn activity_status(status: ToolCallStatus) -> RuntimeActivityStatus {
match status {
ToolCallStatus::Pending => RuntimeActivityStatus::Pending,
ToolCallStatus::InProgress => RuntimeActivityStatus::InProgress,
ToolCallStatus::Completed => RuntimeActivityStatus::Completed,
ToolCallStatus::Failed => RuntimeActivityStatus::Failed,
_ => RuntimeActivityStatus::Other("updated".to_owned()),
}
}
fn stop_reason_from_acp(reason: StopReason) -> AgentStopReason {
match reason {
StopReason::EndTurn => AgentStopReason::Completed,
StopReason::Cancelled => AgentStopReason::Cancelled,
StopReason::MaxTokens | StopReason::MaxTurnRequests => AgentStopReason::MaxTokens,
StopReason::Refusal => AgentStopReason::Refusal,
_ => AgentStopReason::Other(format!("{reason:?}")),
}
}
fn prompt_content(content: MessageContent) -> Result<Vec<ContentBlock>, AgentError> {
match content {
MessageContent::Text(text) => Ok(vec![ContentBlock::Text(TextContent::new(text))]),
MessageContent::MultiPart(parts) => parts.into_iter().map(prompt_part).collect(),
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => Err(invalid_request(
"ACP prompts cannot contain provider tool-use history",
)),
}
}
fn prompt_part(part: ContentPart) -> Result<ContentBlock, AgentError> {
match part {
ContentPart::Text(text) => Ok(ContentBlock::Text(TextContent::new(text))),
ContentPart::Image { data, mime_type } => {
let image = ImageContent::new(
base64::engine::general_purpose::STANDARD.encode(data),
mime_type,
);
Ok(ContentBlock::Image(image))
}
ContentPart::Reasoning { .. }
| ContentPart::ToolUse { .. }
| ContentPart::ToolResult { .. } => Err(invalid_request(
"ACP prompts support only user text and image content",
)),
}
}
fn steering_failure_notice(error: &str) -> String {
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."
)
}
fn invalid_request(message: impl Into<String>) -> AgentError {
AgentError::new(AgentErrorKind::InvalidRequest, message)
}
fn agent_error_from_runtime(error: AcpRuntimeError) -> AgentError {
let kind = match error {
AcpRuntimeError::InvalidTurn(_) => AgentErrorKind::InvalidRequest,
AcpRuntimeError::AuthenticationTimeout(_) => AgentErrorKind::Authentication,
AcpRuntimeError::WorkerSpawn(_)
| AcpRuntimeError::RuntimeClosed(_)
| AcpRuntimeError::InitializationTimeout(_)
| AcpRuntimeError::DiscoveryTimeout(_) => AgentErrorKind::Transport,
AcpRuntimeError::ProcessTreeTeardownUnsupported => AgentErrorKind::Configuration,
AcpRuntimeError::TurnNotActive
| AcpRuntimeError::SteeringUnsupported
| AcpRuntimeError::SteeringTimeout(_)
| AcpRuntimeError::Protocol(_) => AgentErrorKind::Protocol,
};
AgentError::new(kind, error.to_string())
}
#[cfg(test)]
#[path = "agent_runtime_tests.rs"]
mod tests;
+127
View File
@@ -0,0 +1,127 @@
use agent_client_protocol::schema::v1::{
AgentCapabilities, ContentBlock, SessionId, TextContent, ToolCallId, ToolCallStatus,
};
use galaxy_agent_core::{
AgentEvent, ContentPart, MessageContent, RuntimeActivity, RuntimeActivityStatus,
};
use super::*;
#[test]
fn shared_prompt_converts_once_to_official_acp_content() {
let prompt = MessageContent::MultiPart(vec![
ContentPart::Text("describe this".to_owned()),
ContentPart::Image {
data: b"image".to_vec(),
mime_type: "image/png".to_owned(),
},
]);
let content = prompt_content(prompt).expect("prompt conversion");
assert!(matches!(
&content[0],
ContentBlock::Text(text) if text.text == "describe this"
));
assert!(matches!(
&content[1],
ContentBlock::Image(image)
if image.data == "aW1hZ2U="
&& image.mime_type == "image/png"
&& image.uri.is_none()
));
}
#[test]
fn provider_tool_history_is_rejected_as_an_acp_prompt() {
let error = prompt_content(MessageContent::ToolUse {
tool_use_id: "tool-1".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({}),
})
.expect_err("tool history must be rejected");
assert_eq!(error.kind, AgentErrorKind::InvalidRequest);
}
#[test]
fn session_start_updates_state_before_emitting_turn_started() {
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
let mut tool_titles = HashMap::new();
let event = map_event(
AcpEvent::SessionStarted {
session_id: SessionId::new("session-42"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
},
&state,
&mut tool_titles,
)
.expect("event mapping");
assert_eq!(
event,
Some(AgentEvent::TurnStarted {
runtime_request_id: "session-42".to_owned(),
})
);
assert_eq!(
*state.lock().expect("state"),
AcpRuntimeState {
session_id: Some("session-42".to_owned()),
can_load: true,
can_steer: true,
config_options: Vec::new(),
}
);
}
#[test]
fn acp_tool_progress_is_runtime_activity_not_a_host_tool_proposal() {
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
let mut tool_titles = HashMap::new();
let event = map_event(
AcpEvent::ToolCall {
id: ToolCallId::new("tool-1"),
title: "Run tests".to_owned(),
status: ToolCallStatus::InProgress,
output: Some("test_a ... ok".to_owned()),
},
&state,
&mut tool_titles,
)
.expect("event mapping");
assert_eq!(
event,
Some(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: "tool-1".to_owned(),
title: "Run tests".to_owned(),
status: Some(RuntimeActivityStatus::InProgress),
output: Some("test_a ... ok".to_owned()),
},
})
);
}
#[test]
fn unsolicited_user_echo_is_not_exposed_as_visible_input() {
let state = Arc::new(Mutex::new(AcpRuntimeState::default()));
let mut tool_titles = HashMap::new();
let event = map_event(
AcpEvent::UserContent {
content: ContentBlock::Text(TextContent::new("hidden Galaxy context")),
},
&state,
&mut tool_titles,
)
.expect("event mapping");
assert_eq!(event, None);
}
+7 -2
View File
@@ -1,9 +1,11 @@
//! 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.
//! owns the official SDK connection and subprocess, then exposes ACP sessions
//! through `galaxy_agent_core::AgentRuntime`. Low-level ACP events remain an
//! internal protocol boundary rather than leaking into application UI code.
mod agent_runtime;
mod config;
mod events;
mod permissions;
@@ -15,6 +17,9 @@ pub use agent_client_protocol::schema::v1::{
SessionConfigOptionCategory, SessionConfigOptionValue, SessionConfigSelectOption,
SessionConfigSelectOptions, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
};
pub use agent_runtime::{
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle,
};
pub use config::{
AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION,
};
+72 -25
View File
@@ -112,8 +112,6 @@ pub struct AcpTurnRequest {
pub auto_approve_permissions: bool,
/// Category permissions inherited from Galaxy's active execution profile.
pub permission_policy: AcpPermissionPolicy,
/// Prompt capabilities advertised by the agent.
pub prompt_capabilities: agent_client_protocol::schema::v1::PromptCapabilities,
}
impl AcpTurnRequest {
@@ -134,7 +132,6 @@ impl AcpTurnRequest {
mcp_servers: Vec::new(),
auto_approve_permissions: false,
permission_policy: AcpPermissionPolicy::default(),
prompt_capabilities: Default::default(),
}
}
@@ -223,26 +220,6 @@ impl AcpTurnRequest {
}
}
}
for content in &self.prompt {
match content {
ContentBlock::Image(_) if !self.prompt_capabilities.image => {
return Err(AcpRuntimeError::InvalidTurn(
"agent does not advertise image prompt support".to_owned(),
));
}
ContentBlock::Audio(_) if !self.prompt_capabilities.audio => {
return Err(AcpRuntimeError::InvalidTurn(
"agent does not advertise audio prompt support".to_owned(),
));
}
ContentBlock::Resource(_) if !self.prompt_capabilities.embedded_context => {
return Err(AcpRuntimeError::InvalidTurn(
"agent does not advertise embedded context support".to_owned(),
));
}
_ => {}
}
}
Ok(())
}
}
@@ -980,6 +957,11 @@ impl RuntimeActor {
)
};
validate_prompt_capabilities(
&request.prompt,
&self.agent_capabilities.prompt_capabilities,
)?;
if ready {
let session_id = session_id
.ok_or_else(|| AcpRuntimeError::Protocol("ready session has no id".to_owned()))?;
@@ -1525,6 +1507,45 @@ impl RuntimeActor {
}
}
fn validate_prompt_capabilities(
prompt: &[ContentBlock],
capabilities: &agent_client_protocol::schema::v1::PromptCapabilities,
) -> Result<(), AcpRuntimeError> {
for content in prompt {
match content {
ContentBlock::Image(_) => require_prompt_capability(
capabilities.image,
"agent does not advertise image prompt support",
)?,
ContentBlock::Audio(_) => require_prompt_capability(
capabilities.audio,
"agent does not advertise audio prompt support",
)?,
ContentBlock::Resource(_) => require_prompt_capability(
capabilities.embedded_context,
"agent does not advertise embedded context support",
)?,
ContentBlock::Text(_) | ContentBlock::ResourceLink(_) => {}
// ACP content is forward-compatible. New prompt types must be
// rejected until the SDK exposes a matching capability bit.
_ => {
return Err(AcpRuntimeError::InvalidTurn(
"agent prompt contains an unsupported ACP content type".to_owned(),
));
}
}
}
Ok(())
}
fn require_prompt_capability(supported: bool, message: &str) -> Result<(), AcpRuntimeError> {
if supported {
Ok(())
} else {
Err(AcpRuntimeError::InvalidTurn(message.to_owned()))
}
}
fn fail_conversations(conversations: &HashMap<String, ConversationState>, message: &str) {
for state in conversations.values() {
if let Some(active) = state.active.as_ref() {
@@ -1811,6 +1832,7 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
SessionUpdate::AgentMessageChunk(chunk) => Some(event_from_content(chunk.content, false)),
SessionUpdate::AgentThoughtChunk(chunk) => Some(event_from_content(chunk.content, true)),
SessionUpdate::ToolCall(tool_call) => {
let title = visible_tool_title(&tool_call.title, tool_call.raw_input.as_ref());
let output = visible_tool_output(
&tool_call.content,
tool_call.raw_output.as_ref(),
@@ -1818,12 +1840,18 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
);
Some(AcpEvent::ToolCall {
id: tool_call.tool_call_id,
title: tool_call.title,
title,
status: tool_call.status,
output,
})
}
SessionUpdate::ToolCallUpdate(update) => {
let title = update
.fields
.raw_input
.as_ref()
.and_then(command_from_raw_input)
.or(update.fields.title);
let output = visible_tool_output(
update.fields.content.as_deref().unwrap_or_default(),
update.fields.raw_output.as_ref(),
@@ -1831,7 +1859,7 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
);
Some(AcpEvent::ToolCallUpdate {
id: update.tool_call_id,
title: update.fields.title,
title,
status: update.fields.status,
output,
})
@@ -1857,6 +1885,25 @@ fn event_from_session_update(update: SessionUpdate) -> Option<AcpEvent> {
}
}
fn visible_tool_title(title: &str, raw_input: Option<&serde_json::Value>) -> String {
raw_input
.and_then(command_from_raw_input)
.unwrap_or_else(|| title.to_owned())
}
fn command_from_raw_input(raw_input: &serde_json::Value) -> Option<String> {
let object = raw_input.as_object()?;
let command = ["command", "cmd", "shell_command", "shellCommand"]
.into_iter()
.find_map(|key| object.get(key).and_then(serde_json::Value::as_str))?;
let mut visible = VisibleToolOutput::default();
visible.append(command.trim());
visible
.finish()
.map(|command| command.lines().collect::<Vec<_>>().join(" "))
.filter(|command| !command.is_empty())
}
fn visible_tool_output(
content: &[ToolCallContent],
raw_output: Option<&serde_json::Value>,
+37 -4
View File
@@ -7,10 +7,10 @@ use std::task::{Context, Poll};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, InitializeResponse,
McpServer, McpServerStdio, SessionConfigOption, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, ImageContent,
InitializeResponse, McpServer, McpServerStdio, PromptCapabilities, SessionConfigOption,
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionUpdate, TextContent,
ToolCall, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
};
use agent_client_protocol::schema::ProtocolVersion;
@@ -100,6 +100,21 @@ fn turn_validation_rejects_relative_mcp_commands() {
));
}
#[test]
fn prompt_media_uses_capabilities_advertised_by_the_connected_agent() {
let prompt = vec![ContentBlock::Image(ImageContent::new(
"aW1hZ2U=",
"image/png",
))];
let error = validate_prompt_capabilities(&prompt, &PromptCapabilities::default())
.expect_err("image capability is required");
assert!(error.to_string().contains("image prompt support"));
validate_prompt_capabilities(&prompt, &PromptCapabilities::new().image(true))
.expect("advertised image support");
}
#[test]
fn persisted_session_restore_is_rejected_when_not_advertised() {
let requested = SessionId::new("persisted");
@@ -312,6 +327,24 @@ fn tool_and_usage_updates_are_visible_events() {
);
}
#[test]
fn terminal_command_from_raw_input_becomes_the_visible_title() {
let tool = event_from_session_update(SessionUpdate::ToolCall(
ToolCall::new("tool-1", "shell")
.raw_input(serde_json::json!({ "cmd": "git status --short" })),
));
assert_eq!(
tool,
Some(AcpEvent::ToolCall {
id: "tool-1".into(),
title: "git status --short".to_owned(),
status: ToolCallStatus::Pending,
output: None,
})
);
}
#[test]
fn tool_content_is_sanitized_before_becoming_visible_output() {
let tool = event_from_session_update(SessionUpdate::ToolCall(
+4 -1
View File
@@ -34,7 +34,10 @@ pub struct RuntimeDescriptor {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TurnCommand {
Cancel,
Steer { text: String },
Steer {
display_text: String,
model_text: String,
},
}
#[derive(Clone, Debug)]
@@ -103,7 +103,8 @@ fn turn_control_delivers_cancel_and_steering_in_order() {
sender
.send(TurnCommand::Steer {
text: "focus on tests".to_string(),
display_text: "focus on tests".to_string(),
model_text: "focus on tests".to_string(),
})
.await
.unwrap();
@@ -112,7 +113,8 @@ fn turn_control_delivers_cancel_and_steering_in_order() {
assert_eq!(
control.receive().await.unwrap(),
TurnCommand::Steer {
text: "focus on tests".to_string(),
display_text: "focus on tests".to_string(),
model_text: "focus on tests".to_string(),
}
);
assert_eq!(control.receive().await.unwrap(), TurnCommand::Cancel);
+47
View File
@@ -99,6 +99,13 @@ pub struct TurnRequest {
pub conversation_id: Option<String>,
pub model: ModelId,
pub system_prompt: Option<String>,
/// Current runtime input when the runtime owns conversation history.
///
/// Provider runtimes normally consume `messages`, while session-oriented
/// runtimes such as ACP consume this single prompt and retain their own
/// history. Keeping the distinction explicit prevents applications from
/// serializing a prompt into provider history and translating it back.
pub prompt: Option<MessageContent>,
pub messages: Vec<ConversationMessage>,
pub tools: Vec<ToolDefinition>,
pub max_output_tokens: Option<u64>,
@@ -111,12 +118,18 @@ impl TurnRequest {
conversation_id: None,
model: model.into(),
system_prompt: None,
prompt: None,
messages,
tools: Vec::new(),
max_output_tokens: None,
metadata: BTreeMap::new(),
}
}
pub fn with_prompt(mut self, prompt: MessageContent) -> Self {
self.prompt = Some(prompt);
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -233,6 +246,23 @@ pub enum StopReason {
Other(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeActivityStatus {
Pending,
InProgress,
Completed,
Failed,
Other(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeActivity {
pub id: String,
pub title: String,
pub status: Option<RuntimeActivityStatus>,
pub output: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum AgentEvent {
TurnStarted {
@@ -254,6 +284,23 @@ pub enum AgentEvent {
UsageUpdated {
usage: Usage,
},
/// Progress owned by a session runtime rather than a Galaxy-executed tool.
RuntimeActivityUpdated {
activity: RuntimeActivity,
},
/// Current context occupancy reported by a session runtime.
ContextUsageUpdated {
used_tokens: u64,
context_size: u64,
},
/// Visible user input accepted while a turn was already running.
UserInputAccepted {
text: String,
},
/// A displayable runtime lifecycle or permission notice.
RuntimeNotice {
message: String,
},
TurnStopped {
reason: StopReason,
},
@@ -91,3 +91,15 @@ fn denied_results_are_errors_but_cancelled_results_are_distinct() {
assert!(denied.is_error());
assert!(!cancelled.is_error());
}
#[test]
fn session_runtime_prompt_is_distinct_from_provider_history() {
let request = TurnRequest::new("acp:codex", Vec::new())
.with_prompt(MessageContent::Text("inspect the workspace".to_owned()));
assert_eq!(
request.prompt,
Some(MessageContent::Text("inspect the workspace".to_owned()))
);
assert!(request.messages.is_empty());
}