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
+48
View File
@@ -1020,9 +1020,57 @@ fn is_false(value: &bool) -> bool {
!*value
}
/// Backend responsible for executing an agent conversation.
///
/// Existing persisted conversations predate this field and therefore default
/// to Galaxy's native model-provider path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AgentBackend {
#[default]
Provider,
Acp(AcpConversationData),
}
impl AgentBackend {
pub fn is_provider(&self) -> bool {
matches!(self, Self::Provider)
}
/// Copies the backend identity for a locally forked conversation without
/// sharing an agent-owned session between two Galaxy conversations.
pub fn for_fork(&self) -> Self {
match self {
Self::Provider => Self::Provider,
Self::Acp(acp) => Self::Acp(AcpConversationData {
agent_id: acp.agent_id.clone(),
launch_fingerprint: acp.launch_fingerprint.clone(),
session_id: None,
}),
}
}
}
/// Persisted identity for a local Agent Client Protocol conversation.
///
/// Process launch details remain device-local settings. The non-secret launch
/// fingerprint prevents an agent-owned session ID from being handed to a
/// different executable after those settings change.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AcpConversationData {
#[serde(default)]
pub agent_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub launch_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
// Serializes to `conversation_data` column in `agent_conversations`.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct AgentConversationData {
#[serde(default, skip_serializing_if = "AgentBackend::is_provider")]
pub agent_backend: AgentBackend,
pub server_conversation_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
+63 -1
View File
@@ -2,7 +2,28 @@ use std::collections::HashMap;
use warp_multi_agent_api as api;
use super::{AgentConversation, AgentConversationData, ModelTokenUsage};
use super::{
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData, ModelTokenUsage,
};
#[test]
fn acp_backend_fork_keeps_agent_identity_but_clears_session() {
let source = AgentBackend::Acp(AcpConversationData {
agent_id: "codex".to_owned(),
launch_fingerprint: "launch-123".to_owned(),
session_id: Some("shared-session".to_owned()),
});
assert_eq!(
source.for_fork(),
AgentBackend::Acp(AcpConversationData {
agent_id: "codex".to_owned(),
launch_fingerprint: "launch-123".to_owned(),
session_id: None,
})
);
assert_eq!(AgentBackend::Provider.for_fork(), AgentBackend::Provider);
}
fn parentless_task(id: &str, message_count: usize) -> api::Task {
api::Task {
@@ -105,6 +126,7 @@ fn is_restorable_accepts_empty_and_single_task_conversations() {
#[test]
fn agent_conversation_data_roundtrips_last_event_sequence() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,
@@ -141,9 +163,45 @@ fn agent_conversation_data_accepts_legacy_orchestration_avatar_id() {
assert_eq!(data.orchestration_harness_type.as_deref(), Some("orbit"));
}
#[test]
fn agent_conversation_data_defaults_legacy_rows_to_provider_backend() {
let data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null}"#)
.expect("legacy rows must deserialize");
assert_eq!(data.agent_backend, AgentBackend::Provider);
}
#[test]
fn agent_conversation_data_roundtrips_acp_backend() {
let data = AgentConversationData {
agent_backend: AgentBackend::Acp(AcpConversationData {
agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()),
}),
..Default::default()
};
let json = serde_json::to_string(&data).expect("serialize");
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(roundtripped.agent_backend, data.agent_backend);
}
#[test]
fn agent_conversation_data_omits_default_provider_backend() {
let json = serde_json::to_string(&AgentConversationData::default()).expect("serialize");
assert!(
!json.contains("agent_backend"),
"provider backend should retain the legacy serialized shape: {json}"
);
}
#[test]
fn agent_conversation_data_roundtrips_remote_child_marker() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,
@@ -170,6 +228,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
#[test]
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,
@@ -208,6 +267,7 @@ fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequen
#[test]
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,
@@ -236,6 +296,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
#[test]
fn agent_conversation_data_roundtrips_pinned() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,
@@ -262,6 +323,7 @@ fn agent_conversation_data_roundtrips_pinned() {
#[test]
fn agent_conversation_data_skips_serializing_unpinned() {
let data = AgentConversationData {
agent_backend: Default::default(),
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids: None,