use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::schema::v1::{
BlobResourceContents, CancelNotification, ConfigOptionUpdate, ContentBlock,
CreateTerminalRequest, EmbeddedResource, EmbeddedResourceResource, ImageContent,
InitializeRequest, KillTerminalRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
PromptResponse, ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest,
RequestPermissionRequest, SessionConfigId, SessionConfigKind, SessionConfigOption,
SessionConfigOptionValue, SessionId, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, TextContent, TextResourceContents, WaitForTerminalExitRequest,
WriteTextFileRequest, WriteTextFileResponse,
};
use agent_client_protocol::{AcpAgent, Agent as AcpAgentRole, ConnectionTo, Error as AcpError};
use serde::Deserialize;
use tokio::sync::{broadcast, oneshot};
use tokio::task::JoinHandle;
use crate::acp::agent_proc;
use crate::acp::config_prefs;
use crate::acp::handler::{self, SeqNotification};
use crate::acp::permission::{PermissionManager, PermissionRequestEvent};
use crate::acp::terminal::{AcpTerminalManager, TerminalActivity};
use crate::acp::turn_accumulator::{TurnAccumulator, TurnSnapshot, TurnTiming};
use crate::models::agent::Agent;
const SESSION_UPDATE_CHANNEL_CAPACITY: usize = 4096;
const CANCEL_TURN_FALLBACK_SECS: u64 = 15;
#[derive(Debug, Deserialize)]
pub struct ImageInput {
pub data: String,
pub mime_type: String,
#[serde(default)]
pub thumb: Option<ImageThumb>,
}
#[derive(Debug, Deserialize)]
pub struct ImageThumb {
pub data: String,
pub mime_type: String,
}
#[derive(Debug, Deserialize)]
pub struct FileInput {
pub name: String,
pub mime_type: String,
#[serde(default)]
pub size: u64,
pub data: String,
}
#[derive(Debug)]
pub struct ResourceInput {
pub uri: String,
pub label: String,
pub text: String,
}
fn build_prompt_blocks(
text: &str,
images: Vec<ImageInput>,
resources: Vec<ResourceInput>,
files: Vec<FileInput>,
inline_resources: bool,
) -> Vec<ContentBlock> {
let mut blocks = Vec::new();
if !text.is_empty() || (images.is_empty() && files.is_empty()) {
blocks.push(ContentBlock::Text(TextContent::new(text)));
}
for img in images {
blocks.push(ContentBlock::Image(ImageContent::new(img.data, img.mime_type)));
}
if !inline_resources {
for r in resources {
blocks.push(ContentBlock::Resource(EmbeddedResource::new(
EmbeddedResourceResource::TextResourceContents(TextResourceContents::new(
r.text, r.uri,
)),
)));
}
}
for f in files {
blocks.push(ContentBlock::Resource(EmbeddedResource::new(
EmbeddedResourceResource::BlobResourceContents(
BlobResourceContents::new(f.data, file_uri(&f.name)).mime_type(f.mime_type),
),
)));
}
blocks
}
fn file_uri(name: &str) -> String {
let mut encoded = String::with_capacity(name.len());
for ch in name.chars() {
match ch {
' ' => encoded.push_str("%20"),
'%' => encoded.push_str("%25"),
'#' => encoded.push_str("%23"),
'?' => encoded.push_str("%3F"),
_ => encoded.push(ch),
}
}
format!("file:///{}", encoded)
}
#[derive(Debug, Clone)]
pub enum TurnEndEvent {
Done {
stop_reason: String,
row_id: Option<String>,
duration: Option<TurnTiming>,
abnormal: bool,
},
Error {
message: String,
},
}
#[derive(Debug, Clone)]
pub struct SystemNotice {
pub label: String,
pub detail: Option<serde_json::Value>,
}
struct ActivityState {
active_prompt: bool,
prompt_generation: u64,
last_activity: Instant,
}
impl ActivityState {
fn new() -> Self {
Self { active_prompt: false, prompt_generation: 0, last_activity: Instant::now() }
}
}
fn claim_notice_for_generation(noticed: &Mutex<Option<u64>>, generation: u64) -> bool {
let mut noticed = match noticed.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if *noticed == Some(generation) {
return false;
}
*noticed = Some(generation);
true
}
pub struct AcpClient {
connection: ConnectionTo<AcpAgentRole>,
session_id: SessionId,
session_update_tx: broadcast::Sender<SeqNotification>,
_shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
crash_tx: broadcast::Sender<String>,
system_notice_tx: broadcast::Sender<SystemNotice>,
terminal_event_tx: broadcast::Sender<TerminalActivity>,
turn_end_tx: broadcast::Sender<TurnEndEvent>,
terminal_manager: Arc<AcpTerminalManager>,
permission_manager: Arc<PermissionManager>,
supports_load_session: bool,
supports_image: bool,
supports_embedded_context: bool,
initial_config_options: Arc<Mutex<Vec<SessionConfigOption>>>,
available_commands_notif: Arc<Mutex<Option<SessionNotification>>>,
config_prefs: Arc<Mutex<Option<config_prefs::ConfigPrefsHandle>>>,
activity: Arc<Mutex<ActivityState>>,
last_noticed_generation: Mutex<Option<u64>>,
accumulator: Arc<TurnAccumulator>,
agent_pid: Mutex<Option<u32>>,
_abort_tx: Mutex<Option<oneshot::Sender<()>>>,
alive: Arc<AtomicBool>,
}
fn spawn_crash_watcher(
connection_task: JoinHandle<Result<(), AcpError>>,
abort_rx: oneshot::Receiver<()>,
crash_tx: broadcast::Sender<String>,
accumulator: Arc<TurnAccumulator>,
alive: Arc<AtomicBool>,
) {
tokio::spawn(async move {
let mut connection_task = Box::pin(connection_task);
tokio::select! {
joined = connection_task.as_mut() => {
if let Err(e) = joined
&& !e.is_cancelled()
&& alive.load(Ordering::Acquire)
{
accumulator.finalize_turn();
let _ = crash_tx.send(format!("{}", e));
}
}
_ = abort_rx => {
connection_task.abort();
}
}
});
}
fn resolve_fs_path(base: &Path, requested: &Path) -> Result<PathBuf, String> {
let candidate =
if requested.is_absolute() { requested.to_path_buf() } else { base.join(requested) };
let canon_base =
base.canonicalize().map_err(|e| format!("workspace root unresolvable: {}", e))?;
let canon = if candidate.exists() {
candidate.canonicalize().map_err(|e| format!("path resolution failed: {}", e))?
} else if let Some(parent) = candidate.parent() {
let canon_parent =
parent.canonicalize().map_err(|e| format!("parent dir unresolvable: {}", e))?;
canon_parent.join(candidate.file_name().unwrap_or_default())
} else {
candidate
};
if !canon.starts_with(&canon_base) {
return Err("access denied: path escapes workspace root".to_string());
}
Ok(canon)
}
async fn on_agent_notification(
activity: &Arc<Mutex<ActivityState>>,
accumulator: &Arc<TurnAccumulator>,
commands_notif: &Arc<Mutex<Option<SessionNotification>>>,
config_prefs_slot: &Arc<Mutex<Option<config_prefs::ConfigPrefsHandle>>>,
tx: &broadcast::Sender<SeqNotification>,
notification: SessionNotification,
) -> Result<(), agent_client_protocol::Error> {
if let Ok(mut st) = activity.lock() {
st.last_activity = Instant::now();
}
let seq = accumulator.fold(¬ification);
if matches!(notification.update, SessionUpdate::AvailableCommandsUpdate(_))
&& let Ok(mut guard) = commands_notif.lock()
{
*guard = Some(notification.clone());
}
if let SessionUpdate::ConfigOptionUpdate(u) = ¬ification.update
&& let Some(handle) = config_prefs_slot.lock().ok().and_then(|g| g.clone())
{
config_prefs::persist_config_snapshot(&handle, &u.config_options).await;
}
handler::handle_session_update(tx, SeqNotification { seq, notification })
}
enum SessionMode {
New,
Load(String),
}
impl AcpClient {
pub async fn spawn_and_connect(
agent: Agent,
cwd: PathBuf,
api_keys: &std::collections::HashMap<String, String>,
) -> Result<Self, AcpError> {
Self::spawn_with_session(agent, cwd, api_keys, SessionMode::New).await
}
async fn spawn_with_session(
agent: Agent,
cwd: PathBuf,
api_keys: &std::collections::HashMap<String, String>,
mode: SessionMode,
) -> Result<Self, AcpError> {
let resolved_cmd = match agent.npm_package.as_deref() {
Some(_) => {
crate::acp::resolve::resolve_command(&agent.command, agent.npm_package.as_deref())
.await
.map_err(|e| AcpError::internal_error().data(e))?
.to_string_lossy()
.to_string()
}
None => agent.command.clone(),
};
let mut all_args: Vec<String> = Vec::new();
for env_var in &agent.env {
all_args.push(format!("{}={}", env_var.key, env_var.value));
}
for (key, value) in api_keys {
if !agent.env.iter().any(|e| e.key == *key) {
all_args.push(format!("{}={}", key, value));
}
}
#[cfg(unix)]
let pid_file = agent_proc::new_pid_file();
#[cfg(unix)]
let (cmd, args) = (
"/bin/sh".to_string(),
agent_proc::wrap_agent_with_cwd(&resolved_cmd, &agent.args, &cwd, Some(&pid_file)),
);
#[cfg(not(unix))]
let (cmd, args) = (resolved_cmd, agent.args.clone());
all_args.push(cmd);
all_args.extend(args);
let transport = AcpAgent::from_args(all_args)?;
let (session_update_tx, _) = broadcast::channel(SESSION_UPDATE_CHANNEL_CAPACITY);
let (crash_tx, _) = broadcast::channel::<String>(16);
let (system_notice_tx, _) = broadcast::channel::<SystemNotice>(8);
let (terminal_event_tx, _) = broadcast::channel::<TerminalActivity>(64);
let (turn_end_tx, _) = broadcast::channel::<TurnEndEvent>(16);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (abort_tx, abort_rx) = oneshot::channel::<()>();
let (conn_tx, conn_rx) = oneshot::channel::<(
ConnectionTo<AcpAgentRole>,
SessionId,
bool,
bool,
bool,
Vec<SessionConfigOption>,
)>();
let notif_tx = session_update_tx.clone();
let terminal_manager = Arc::new(AcpTerminalManager::new(terminal_event_tx.clone()));
let tm = terminal_manager.clone();
let permission_manager = Arc::new(PermissionManager::new());
let pm = permission_manager.clone();
let activity = Arc::new(Mutex::new(ActivityState::new()));
let commands_notif: Arc<Mutex<Option<SessionNotification>>> = Arc::new(Mutex::new(None));
let accumulator = Arc::new(TurnAccumulator::new());
let config_prefs_slot: Arc<Mutex<Option<config_prefs::ConfigPrefsHandle>>> =
Arc::new(Mutex::new(None));
let alive = Arc::new(AtomicBool::new(true));
let builder = agent_client_protocol::Client
.builder()
.name("omniterm")
.on_receive_notification(
{
let tx = notif_tx.clone();
let activity = activity.clone();
let commands_notif = commands_notif.clone();
let accumulator = accumulator.clone();
let config_prefs_slot = config_prefs_slot.clone();
async move |notification: SessionNotification, _cx| {
on_agent_notification(
&activity,
&accumulator,
&commands_notif,
&config_prefs_slot,
&tx,
notification,
)
.await
}
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
{
let pm = pm.clone();
let accumulator = accumulator.clone();
async move |request: RequestPermissionRequest, responder, _cx| {
pm.handle_request(request, responder).await?;
accumulator.begin_wait();
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let read_cwd = cwd.clone();
async move |request: ReadTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&read_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
let _ = responder.respond(ReadTextFileResponse::new(content));
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("read failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let write_cwd = cwd.clone();
async move |request: WriteTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&write_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(&path, &request.content).await {
Ok(()) => {
let _ = responder.respond(WriteTextFileResponse::new());
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("write failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: CreateTerminalRequest, responder, _cx| {
tm.handle_create(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: agent_client_protocol::schema::v1::TerminalOutputRequest, responder, _cx| {
tm.handle_output(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: KillTerminalRequest, responder, _cx| {
tm.handle_kill(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: ReleaseTerminalRequest, responder, _cx| {
tm.handle_release(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: WaitForTerminalExitRequest, responder, _cx| {
tm.handle_wait_for_exit(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
);
#[cfg(unix)]
let scan_workspace = cwd.clone();
#[cfg(target_os = "linux")]
let children_before = agent_proc::snapshot_direct_children();
#[cfg(all(unix, not(target_os = "linux")))]
let children_before = std::collections::HashSet::new();
#[cfg(unix)]
let pid_file_cleanup = agent_proc::PidFileCleanup::new(pid_file.clone());
let connection_task = tokio::spawn(async move {
#[cfg(unix)]
let _pid_file_cleanup = pid_file_cleanup;
builder
.connect_with(transport, move |cx: ConnectionTo<AcpAgentRole>| async move {
let init_resp = cx
.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
let supports_load = init_resp.agent_capabilities.load_session;
let supports_image = init_resp.agent_capabilities.prompt_capabilities.image;
let supports_embedded =
init_resp.agent_capabilities.prompt_capabilities.embedded_context;
let (session_id, config_options) = match mode {
SessionMode::New => {
let session_resp =
cx.send_request(NewSessionRequest::new(cwd)).block_task().await?;
(
session_resp.session_id,
session_resp.config_options.clone().unwrap_or_default(),
)
}
SessionMode::Load(acp_session_id) => {
(SessionId::new(acp_session_id.as_str()), Vec::new())
}
};
let _ = conn_tx.send((
cx.clone(),
session_id,
supports_load,
supports_image,
supports_embedded,
config_options,
));
let _ = shutdown_rx.await;
Ok(())
})
.await
});
spawn_crash_watcher(
connection_task,
abort_rx,
crash_tx.clone(),
accumulator.clone(),
alive.clone(),
);
let (
connection,
session_id,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options,
) = match conn_rx.await {
Ok(parts) => parts,
Err(_) => {
#[cfg(unix)]
agent_proc::remove_pid_file(&pid_file);
return Err(AcpError::internal_error());
}
};
#[cfg(unix)]
let agent_pid = agent_proc::capture_agent_pid(&pid_file, &children_before, &scan_workspace);
#[cfg(not(unix))]
let agent_pid: Option<u32> = {
tracing::warn!(
"非 Unix 平台:ACP agent pid 不可得(无 wrapper 自报与 /proc 扫描),释放时仅发优雅关闭信号"
);
None
};
Ok(AcpClient {
connection,
session_id,
session_update_tx,
_shutdown_tx: Mutex::new(Some(shutdown_tx)),
_abort_tx: Mutex::new(Some(abort_tx)),
crash_tx,
system_notice_tx,
terminal_event_tx,
turn_end_tx,
terminal_manager,
permission_manager,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options: Arc::new(Mutex::new(initial_config_options)),
available_commands_notif: commands_notif,
activity,
last_noticed_generation: Mutex::new(None),
accumulator,
config_prefs: config_prefs_slot,
alive,
agent_pid: Mutex::new(agent_pid),
})
}
pub fn session_update_subscribe(&self) -> broadcast::Receiver<SeqNotification> {
self.session_update_tx.subscribe()
}
pub fn crash_subscribe(&self) -> broadcast::Receiver<String> {
self.crash_tx.subscribe()
}
pub fn system_notice_subscribe(&self) -> broadcast::Receiver<SystemNotice> {
self.system_notice_tx.subscribe()
}
pub fn terminal_event_subscribe(&self) -> broadcast::Receiver<TerminalActivity> {
self.terminal_event_tx.subscribe()
}
pub fn turn_end_subscribe(&self) -> broadcast::Receiver<TurnEndEvent> {
self.turn_end_tx.subscribe()
}
pub fn turn_row_id(&self) -> Option<String> {
self.accumulator.turn_row_id()
}
pub fn agent_pid(&self) -> Option<u32> {
*self.agent_pid.lock().unwrap()
}
pub fn turn_timing(&self) -> Option<TurnTiming> {
self.accumulator.turn_timing()
}
pub fn prompt_generation(&self) -> u64 {
self.activity.lock().map(|st| st.prompt_generation).unwrap_or(0)
}
pub fn claim_turn_end_notice_for_current_turn(&self) -> bool {
let generation = match self.activity.lock() {
Ok(st) => st.prompt_generation,
Err(poisoned) => poisoned.into_inner().prompt_generation,
};
claim_notice_for_generation(&self.last_noticed_generation, generation)
}
pub fn notify_turn_end(&self, event: TurnEndEvent) {
let _ = self.turn_end_tx.send(event);
}
pub fn notify_system_message(&self, notice: SystemNotice) {
let _ = self.system_notice_tx.send(notice);
}
pub fn permission_subscribe(&self) -> broadcast::Receiver<PermissionRequestEvent> {
self.permission_manager.subscribe()
}
pub fn permission_resolved_subscribe(&self) -> broadcast::Receiver<String> {
self.permission_manager.resolved_subscribe()
}
pub async fn resolve_permission(&self, id: &str, option_id: &str) -> bool {
let resolved = self.permission_manager.resolve(id, option_id).await;
if resolved {
self.accumulator.end_wait();
}
resolved
}
pub async fn set_config_option(&self, config_id: &str, value: &str) -> Result<(), AcpError> {
let config_id: Arc<str> = config_id.into();
let value: Arc<str> = value.into();
let is_boolean = self
.initial_config_options
.lock()
.ok()
.map(|opts| {
opts.iter()
.any(|o| o.id.0 == config_id && matches!(o.kind, SessionConfigKind::Boolean(_)))
})
.unwrap_or(false);
let option_value = if is_boolean {
SessionConfigOptionValue::boolean(value.as_ref() == "true")
} else {
SessionConfigOptionValue::from(value.as_ref())
};
let resp = self
.connection
.send_request(SetSessionConfigOptionRequest::new(
self.session_id.clone(),
SessionConfigId::new(config_id.clone()),
option_value,
))
.block_task()
.await?;
let new_opts = resp.config_options;
if !new_opts.is_empty() {
if let Ok(mut guard) = self.initial_config_options.lock() {
*guard = new_opts.clone();
}
let notification = SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(new_opts.clone())),
);
let _ = self.session_update_tx.send(SeqNotification { seq: None, notification });
}
if let Some(handle) = self.config_prefs.lock().ok().and_then(|g| g.clone()) {
config_prefs::persist_config_snapshot(&handle, &new_opts).await;
}
if let Some(handle) = self.config_prefs.lock().ok().and_then(|g| g.clone()) {
if let Err(e) = config_prefs::save_session_config(
&handle.db,
&handle.db_session_id,
&config_id,
&value,
)
.await
{
tracing::warn!(config_id = %config_id, "save session config failed: {}", e);
}
if let Err(e) =
config_prefs::save_agent_pref(&handle.db, &handle.agent_id, &config_id, &value)
.await
{
tracing::warn!(config_id = %config_id, "save agent config pref failed: {}", e);
}
}
Ok(())
}
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
pub async fn send_prompt(
&self,
text: &str,
images: Vec<ImageInput>,
resources: Vec<ResourceInput>,
files: Vec<FileInput>,
) -> Result<PromptResponse, AcpError> {
let inline_resources = !self.supports_embedded_context && !resources.is_empty();
let text = if inline_resources {
let mut t = text.to_string();
for r in &resources {
t.push_str(&format!("\n\n--- @{} ---\n```\n{}\n```", r.label, r.text));
}
t
} else {
text.to_string()
};
let blocks = build_prompt_blocks(&text, images, resources, files, inline_resources);
self.connection
.send_request(PromptRequest::new(self.session_id.clone(), blocks))
.block_task()
.await
}
pub fn cancel(&self) -> Result<(), AcpError> {
self.connection.send_notification(CancelNotification::new(self.session_id.clone()))?;
let pm = self.permission_manager.clone();
tokio::spawn(async move { pm.cancel_all().await });
self.accumulator.end_all_waits();
let tm = self.terminal_manager.clone();
tokio::spawn(async move { tm.kill_all().await });
Ok(())
}
pub fn supports_load_session(&self) -> bool {
self.supports_load_session
}
pub fn supports_image(&self) -> bool {
self.supports_image
}
pub fn supports_embedded_context(&self) -> bool {
self.supports_embedded_context
}
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::Acquire) && !self.connection.is_incoming_closed()
}
pub fn mark_activity(&self) {
if let Ok(mut st) = self.activity.lock() {
st.last_activity = Instant::now();
}
}
pub fn mark_prompt_active(&self) {
if let Ok(mut st) = self.activity.lock() {
st.active_prompt = true;
st.prompt_generation = st.prompt_generation.wrapping_add(1);
st.last_activity = Instant::now();
}
self.accumulator.begin_turn();
}
pub fn mark_prompt_idle(&self) {
if let Ok(mut st) = self.activity.lock() {
st.active_prompt = false;
}
self.accumulator.finalize_turn();
}
pub fn spawn_cancel_turn_fallback(self: &Arc<Self>) {
let generation = {
let Ok(st) = self.activity.lock() else { return };
if !st.active_prompt {
return;
}
st.prompt_generation
};
let client = Arc::clone(self);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(CANCEL_TURN_FALLBACK_SECS)).await;
let same_turn_still_active = {
let Ok(st) = client.activity.lock() else { return };
st.active_prompt && st.prompt_generation == generation
};
if same_turn_still_active {
tracing::warn!(
"agent 未在 {}s 内响应 session/cancel,强制定稿进行中 turn(兜底)",
CANCEL_TURN_FALLBACK_SECS
);
client.mark_prompt_idle();
client.notify_turn_end(TurnEndEvent::Done {
stop_reason: "Cancelled".into(),
row_id: client.turn_row_id(),
duration: client.turn_timing(),
abnormal: false,
});
}
});
}
pub fn attach_persistence(&self, db: sqlx::SqlitePool, db_session_id: String) {
self.accumulator.attach_persistence(db, db_session_id);
}
pub async fn attach_config_prefs(
&self,
db: sqlx::SqlitePool,
db_session_id: String,
agent_id: String,
) {
if let Ok(mut guard) = self.config_prefs.lock() {
*guard = Some(config_prefs::ConfigPrefsHandle { db, db_session_id, agent_id });
}
let opts = self.initial_config_options.lock().ok().map(|g| g.clone()).unwrap_or_default();
if let Some(handle) = self.config_prefs.lock().ok().and_then(|g| g.clone()) {
config_prefs::persist_config_snapshot(&handle, &opts).await;
}
}
pub async fn restore_config_prefs(&self) {
let Some(handle) = self.config_prefs.lock().ok().and_then(|g| g.clone()) else {
return;
};
let agent =
config_prefs::list_agent_prefs(&handle.db, &handle.agent_id).await.unwrap_or_default();
let session = config_prefs::list_session_configs(&handle.db, &handle.db_session_id)
.await
.unwrap_or_default();
let merged = config_prefs::merge_prefs(agent, session);
if merged.is_empty() {
return;
}
let opts = self.initial_config_options.lock().ok().map(|g| g.clone()).unwrap_or_default();
let client = self;
let applied = async move {
for (config_id, value) in merged {
let Some(opt) = opts.iter().find(|o| o.id.0.as_ref() == config_id) else {
continue;
};
if !config_prefs::validate_config_value(opt, &value) {
tracing::warn!(
config_id = %config_id,
value = %value,
"restore config value rejected (not in agent options); skipping"
);
continue;
}
if let Err(e) = client.set_config_option(&config_id, &value).await {
tracing::warn!(config_id = %config_id, "restore config option failed: {}", e);
}
}
};
match tokio::time::timeout(Duration::from_secs(10), applied).await {
Ok(()) => {}
Err(_) => tracing::warn!("restore_config_prefs timed out after 10s"),
}
}
pub fn turn_snapshot(&self) -> TurnSnapshot {
self.accumulator.turn_snapshot()
}
pub async fn pending_permissions(&self) -> usize {
self.permission_manager.pending_count().await
}
pub async fn pending_permission_events(&self) -> Vec<PermissionRequestEvent> {
self.permission_manager.pending_events().await
}
pub async fn is_idle_stale(&self, idle_secs: u64) -> bool {
let (active_prompt, last_activity) = {
let st = self.activity.lock().unwrap();
(st.active_prompt, st.last_activity)
};
let pending = self.permission_manager.pending_count().await;
!active_prompt && pending == 0 && last_activity.elapsed().as_secs() >= idle_secs
}
pub async fn is_permission_stale(&self, perm_secs: u64) -> bool {
let last_activity = {
let st = self.activity.lock().unwrap();
st.last_activity
};
let pending = self.permission_manager.pending_count().await;
pending > 0 && last_activity.elapsed().as_secs() >= perm_secs
}
pub fn is_prompt_stale(&self, stale_secs: u64) -> bool {
let Ok(st) = self.activity.lock() else { return false };
st.active_prompt && st.last_activity.elapsed().as_secs() >= stale_secs
}
pub async fn load_session(&self, acp_session_id: &str, cwd: PathBuf) -> Result<(), AcpError> {
let resp = self
.connection
.send_request(LoadSessionRequest::new(SessionId::new(acp_session_id), cwd))
.block_task()
.await?;
let opts: Option<Vec<SessionConfigOption>> =
resp.config_options.filter(|o| !o.is_empty()).or_else(|| {
self.initial_config_options.lock().ok().map(|g| g.clone()).filter(|g| !g.is_empty())
});
if let Some(opts) = opts {
if let Ok(mut guard) = self.initial_config_options.lock() {
*guard = opts.clone();
}
if let Some(handle) = self.config_prefs.lock().ok().and_then(|g| g.clone()) {
config_prefs::persist_config_snapshot(&handle, &opts).await;
}
let notification = SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(opts)),
);
let _ = self.session_update_tx.send(SeqNotification { seq: None, notification });
}
Ok(())
}
pub fn initial_config_notification(&self) -> Option<SessionNotification> {
let opts = self.initial_config_options.lock().ok()?.clone();
if opts.is_empty() {
return None;
}
Some(SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(opts)),
))
}
pub fn initial_commands_notification(&self) -> Option<SessionNotification> {
self.available_commands_notif.lock().ok()?.clone()
}
pub async fn spawn_and_load(
agent: Agent,
cwd: PathBuf,
acp_session_id: String,
api_keys: &std::collections::HashMap<String, String>,
) -> Result<Self, AcpError> {
Self::spawn_with_session(agent, cwd, api_keys, SessionMode::Load(acp_session_id)).await
}
pub async fn shutdown(&self) {
self.alive.store(false, Ordering::Release);
self.mark_prompt_idle();
self.terminal_manager.kill_all().await;
let pid = self.agent_pid();
tracing::debug!(?pid, "ACP shutdown: kill agent 进程组(D2),随后发优雅关闭信号");
agent_proc::kill_agent_process_group(pid);
let _ = self._shutdown_tx.lock().unwrap().take();
if let Ok(mut guard) = self._abort_tx.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
pub async fn disconnect(self) {
self.alive.store(false, Ordering::Release);
self.mark_prompt_idle();
self.terminal_manager.kill_all().await;
let pid = self.agent_pid();
tracing::debug!(?pid, "ACP disconnect: kill agent 进程组(D2),随后发优雅关闭信号");
agent_proc::kill_agent_process_group(pid);
if let Ok(mut guard) = self._shutdown_tx.try_lock() {
let _ = guard.take();
}
if let Ok(mut guard) = self._abort_tx.try_lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn img(data: &str) -> ImageInput {
ImageInput { data: data.into(), mime_type: "image/png".into(), thumb: None }
}
fn file(name: &str, data: &str) -> FileInput {
FileInput {
name: name.into(),
mime_type: "application/pdf".into(),
size: 3,
data: data.into(),
}
}
fn at_resource(label: &str) -> ResourceInput {
ResourceInput {
uri: format!("file:///w/{}", label),
label: label.into(),
text: "fn main() {}".into(),
}
}
#[test]
fn prompt_blocks_text_only_has_single_text_block() {
let blocks = build_prompt_blocks("hi", vec![], vec![], vec![], false);
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Text(_)));
}
#[test]
fn prompt_blocks_empty_prompt_without_attachments_keeps_empty_text_block() {
let blocks = build_prompt_blocks("", vec![], vec![], vec![], false);
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Text(_)));
}
#[test]
fn prompt_blocks_image_only_omits_empty_text_block() {
let blocks = build_prompt_blocks("", vec![img("AAA")], vec![], vec![], false);
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Image(_)));
}
#[test]
fn prompt_blocks_file_only_omits_empty_text_block() {
let blocks = build_prompt_blocks("", vec![], vec![], vec![file("a.pdf", "AAA")], false);
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Resource(_)));
}
#[test]
fn prompt_blocks_order_text_image_text_resource_blob() {
let blocks = build_prompt_blocks(
"hi",
vec![img("IMG")],
vec![at_resource("a.rs")],
vec![file("d.pdf", "PDF")],
false,
);
assert_eq!(blocks.len(), 4);
assert!(matches!(blocks[0], ContentBlock::Text(_)));
assert!(matches!(blocks[1], ContentBlock::Image(_)));
assert!(matches!(blocks[2], ContentBlock::Resource(_)));
assert!(matches!(blocks[3], ContentBlock::Resource(_)));
}
#[test]
fn prompt_blocks_file_maps_to_blob_resource_with_mime_and_uri() {
let blocks =
build_prompt_blocks("", vec![], vec![], vec![file("my file.pdf", "PDFDATA")], false);
let blob = match &blocks[0] {
ContentBlock::Resource(res) => match &res.resource {
EmbeddedResourceResource::BlobResourceContents(b) => b,
_ => panic!("expected blob resource contents"),
},
_ => panic!("expected resource block"),
};
assert_eq!(blob.blob, "PDFDATA");
assert_eq!(blob.mime_type.as_deref(), Some("application/pdf"));
assert_eq!(blob.uri, "file:///my%20file.pdf");
}
#[test]
fn prompt_blocks_inlines_at_resources_when_embedded_unsupported() {
let inlined = "see\n\n--- @a.rs ---\n```\nfn main() {}\n```";
let blocks = build_prompt_blocks(inlined, vec![], vec![at_resource("a.rs")], vec![], true);
assert_eq!(blocks.len(), 1);
let ContentBlock::Text(t) = &blocks[0] else {
panic!("expected text block");
};
assert!(t.text.contains("@a.rs"));
}
#[test]
fn file_uri_percent_encodes_uri_breakers() {
assert_eq!(file_uri("a b#c?d%e.pdf"), "file:///a%20b%23c%3Fd%25e.pdf");
assert_eq!(file_uri("报告.pdf"), "file:///报告.pdf");
}
#[test]
fn claim_notice_first_call_for_a_generation_wins() {
let noticed = Mutex::new(None);
assert!(claim_notice_for_generation(¬iced, 1), "首个世代必然赢得留痕权");
}
#[test]
fn claim_notice_same_generation_is_deduplicated() {
let noticed = Mutex::new(None);
assert!(claim_notice_for_generation(¬iced, 7));
assert!(!claim_notice_for_generation(¬iced, 7), "同世代第二次必须被拦");
assert!(!claim_notice_for_generation(¬iced, 7), "第三次同样被拦");
assert_eq!(*noticed.lock().unwrap(), Some(7));
}
#[test]
fn claim_notice_next_generation_gets_its_own_slot() {
let noticed = Mutex::new(None);
assert!(claim_notice_for_generation(¬iced, 1));
assert!(!claim_notice_for_generation(¬iced, 1));
assert!(claim_notice_for_generation(¬iced, 2), "新世代必须重新赢得留痕权");
assert!(!claim_notice_for_generation(¬iced, 2));
assert_eq!(*noticed.lock().unwrap(), Some(2));
}
#[test]
fn claim_notice_generation_zero_is_a_real_generation_not_sentinel() {
let noticed = Mutex::new(None);
assert!(claim_notice_for_generation(¬iced, 0), "世代 0 也应当赢得留痕权");
assert!(!claim_notice_for_generation(¬iced, 0), "世代 0 的重复 claim 同样被拦");
assert_eq!(*noticed.lock().unwrap(), Some(0));
}
#[test]
fn claim_notice_poisoned_lock_still_grants_rather_than_silently_dropping() {
let noticed = Mutex::new(None);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = noticed.lock().unwrap();
panic!("poison the lock on purpose");
}));
assert!(noticed.is_poisoned(), "前提:锁已被毒化");
assert!(claim_notice_for_generation(¬iced, 3), "中毒也必须继续判定(宁重复,不吞)");
assert!(!claim_notice_for_generation(¬iced, 3), "中毒下去重仍然有效");
}
#[test]
fn claim_notice_concurrent_callers_yield_exactly_one_winner_per_generation() {
use std::sync::Barrier;
const CALLERS: usize = 32;
let noticed = std::sync::Arc::new(Mutex::new(None));
let barrier = std::sync::Arc::new(Barrier::new(CALLERS));
let winners = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut handles = Vec::with_capacity(CALLERS);
for _ in 0..CALLERS {
let noticed = std::sync::Arc::clone(¬iced);
let barrier = std::sync::Arc::clone(&barrier);
let winners = std::sync::Arc::clone(&winners);
handles.push(std::thread::spawn(move || {
barrier.wait();
if claim_notice_for_generation(¬iced, 42) {
winners.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}));
}
for h in handles {
h.join().expect("worker thread must not panic");
}
assert_eq!(
winners.load(std::sync::atomic::Ordering::SeqCst),
1,
"同一世代的并发 claim 必须恰好一个赢家"
);
assert_eq!(*noticed.lock().unwrap(), Some(42));
}
#[test]
fn claim_for_current_turn_lock_order_is_deadlock_free_under_contention() {
use std::sync::Barrier;
const THREADS: usize = 16;
let activity = std::sync::Arc::new(Mutex::new(ActivityState::new()));
let noticed = std::sync::Arc::new(Mutex::new(None));
let barrier = std::sync::Arc::new(Barrier::new(THREADS));
let winners = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut handles = Vec::with_capacity(THREADS);
for i in 0..THREADS {
let activity = std::sync::Arc::clone(&activity);
let noticed = std::sync::Arc::clone(¬iced);
let barrier = std::sync::Arc::clone(&barrier);
let winners = std::sync::Arc::clone(&winners);
handles.push(std::thread::spawn(move || {
barrier.wait();
let generation = {
let st = activity.lock().unwrap();
st.prompt_generation + i as u64
};
if claim_notice_for_generation(¬iced, generation) {
winners.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}));
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
for (idx, h) in handles.into_iter().enumerate() {
while !h.is_finished() {
assert!(
std::time::Instant::now() < deadline,
"线程 {idx} 超过 30s 未结束 —— 锁顺序疑似成环(死锁)"
);
std::thread::yield_now();
}
h.join().expect("worker thread must not panic");
}
assert_eq!(
winners.load(std::sync::atomic::Ordering::SeqCst),
THREADS,
"不同世代各自赢得留痕权(顺序正确时无一被吞)"
);
}
}