use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tokio::sync::{broadcast, Notify};
use tokio::task::JoinHandle;
use crate::core::error::AgentError;
use crate::core::types::{
AgentEvent, CliTool, HostDecisionAuthority, HostRequestDecision, HostRequestOutcome, StopReason,
};
use crate::rpc::id::IdGen;
use crate::rpc::message::{RpcId, RpcNotification, RpcRequest, RpcResponse};
use crate::rpc::pending::{PendingRequests, RpcResult};
use super::gate::DangerousCommandGate;
use super::host::{AcpHostAdapter, HostPolicy, PermissionDeferral, PolicyHostHandler};
use super::protocol::{
extract_token_usage, AgentCapabilities, AvailableCommand, ClientInfo, ContentBlock,
InitializeParams, McpServerConfig, PermissionOption, PermissionOptionKind, PermissionOutcome,
PermissionRequestParams, SessionCancelParams, SessionCloseParams, SessionCloseResult,
SessionConfigOption, SessionDeleteParams, SessionForkParams, SessionListParams,
SessionListResult, SessionLoadParams, SessionLoadResult, SessionMode, SessionModel,
SessionNewParams, SessionPromptParams, SessionPromptResult, SessionSetConfigOptionParams,
SessionSetModeParams, SessionState, SessionSummary, SessionUsage,
};
use super::reader::{acp_reader_loop, write_line_to_process};
use super::spawn::AcpProcess;
use gate4agent_types::LaunchSpec;
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
#[error("Process spawn failed: {source}")]
Spawn {
#[source]
source: std::io::Error,
},
#[error("Stdin write failed: {source}")]
Write {
#[source]
source: std::io::Error,
},
#[error("JSON error: {source}")]
Json {
#[source]
source: serde_json::Error,
},
#[error("Handshake timed out (step={step})")]
HandshakeTimeout { step: &'static str },
#[error("Handshake failed: {message}")]
HandshakeFailed { message: String },
#[error("Authentication required: {vendor_message}")]
AuthenticationRequired { vendor_message: String },
#[error("Agent returned RPC error: {0}")]
Agent(#[from] crate::rpc::message::RpcError),
#[error("Request timed out (method={method})")]
Timeout { method: String },
#[error("Session not initialized — call session_new() first")]
NoSession,
#[error("Session closed while awaiting response")]
SessionClosed,
#[error("Agent does not advertise the '{capability}' session capability")]
UnsupportedCapability { capability: &'static str },
}
pub const ACP_GRACEFUL_STOP_BOUND_SECS: u64 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcpStopOutcome {
pub exit_code: Option<i32>,
pub forced: bool,
}
pub struct AcpSessionOptions {
pub channel_capacity: usize,
pub handshake_timeout: Duration,
pub prompt_timeout: Duration,
pub prompt_idle_timeout: Duration,
pub host_policy: HostPolicy,
pub dangerous_command_gate: DangerousCommandGate,
pub approval_level_args: Vec<String>,
pub additional_directories: Vec<String>,
pub mcp_servers: Vec<McpServerConfig>,
pub defer_permission_requests: bool,
pub permission_request_deadline: Duration,
}
impl Default for AcpSessionOptions {
fn default() -> Self {
Self {
channel_capacity: 256,
handshake_timeout: Duration::from_secs(30),
prompt_timeout: Duration::from_secs(120),
prompt_idle_timeout: Duration::from_secs(600),
host_policy: HostPolicy::default(),
dangerous_command_gate: DangerousCommandGate::default(),
approval_level_args: Vec::new(),
additional_directories: Vec::new(),
mcp_servers: Vec::new(),
defer_permission_requests: false,
permission_request_deadline: Duration::from_secs(300),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct PendingPermissionRequest {
pub(crate) params: PermissionRequestParams,
pub(crate) deadline: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperatorPermissionChoice {
Approve,
Reject,
}
#[derive(Clone, Default)]
pub(crate) struct PendingHostRequests {
inner: Arc<Mutex<HashMap<RpcId, PendingPermissionRequest>>>,
}
impl PendingHostRequests {
pub(crate) fn insert(&self, id: RpcId, request: PendingPermissionRequest) {
if let Ok(mut guard) = self.inner.lock() {
guard.insert(id, request);
}
}
fn remove(&self, id: &RpcId) -> Option<PendingPermissionRequest> {
self.inner.lock().ok().and_then(|mut guard| guard.remove(id))
}
fn take_expired(&self, now: Instant) -> Vec<(RpcId, PendingPermissionRequest)> {
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
let expired_ids: Vec<RpcId> = guard
.iter()
.filter(|(_, request)| request.deadline <= now)
.map(|(id, _)| id.clone())
.collect();
expired_ids
.into_iter()
.filter_map(|id| {
let request = guard.remove(&id)?;
Some((id, request))
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PendingRequestResolution {
Answered,
TimedOut,
}
#[derive(Debug, thiserror::Error)]
pub enum PendingRequestError {
#[error("no pending host request with id {id:?} — never deferred, or already answered")]
NotFound { id: RpcId },
}
pub struct AcpSession {
local_session_id: String,
acp_session_id: Arc<tokio::sync::Mutex<Option<String>>>,
provider_reported_session_id: Arc<tokio::sync::Mutex<Option<String>>>,
tool: CliTool,
tx: broadcast::Sender<AgentEvent>,
process: Arc<Mutex<AcpProcess>>,
pending: PendingRequests,
id_gen: Arc<IdGen>,
reader_task: JoinHandle<()>,
prompt_timeout: Duration,
prompt_idle_timeout: Duration,
turn_activity: Arc<Notify>,
host_policy: HostPolicy,
agent_caps: AgentCapabilities,
session_state: Arc<Mutex<SessionState>>,
pending_host_requests: PendingHostRequests,
}
impl AcpSession {
pub async fn spawn(
tool: CliTool,
working_dir: &std::path::Path,
options: AcpSessionOptions,
) -> Result<Self, AcpError> {
let process = AcpProcess::spawn(tool, working_dir, &[], &options.approval_level_args)
.map_err(|source| AcpError::Spawn { source })?;
Self::spawn_process(tool, working_dir, options, process).await
}
pub async fn spawn_with_launch(
tool: CliTool,
working_dir: &std::path::Path,
options: AcpSessionOptions,
launch: &LaunchSpec,
) -> Result<Self, AcpError> {
let process = AcpProcess::spawn_with_launch(working_dir, &[], launch)
.map_err(|source| AcpError::Spawn { source })?;
Self::spawn_process(tool, working_dir, options, process).await
}
async fn spawn_process(
tool: CliTool,
working_dir: &std::path::Path,
options: AcpSessionOptions,
proc: AcpProcess,
) -> Result<Self, AcpError> {
let local_session_id = generate_session_id();
let (tx, _) = broadcast::channel::<AgentEvent>(options.channel_capacity);
let _ = tx.send(AgentEvent::Started {
session_id: local_session_id.clone(),
});
let process = Arc::new(Mutex::new(proc));
let deferral = if options.defer_permission_requests {
PermissionDeferral::Enabled
} else {
PermissionDeferral::Disabled
};
let handler: Arc<AcpHostAdapter> = Arc::new(AcpHostAdapter::new(
Arc::new(PolicyHostHandler::new(
options.host_policy,
working_dir.to_path_buf(),
options.dangerous_command_gate,
)),
deferral,
));
let pending = PendingRequests::new();
let id_gen = Arc::new(IdGen::new());
let session_state = Arc::new(Mutex::new(SessionState::default()));
let pending_host_requests = PendingHostRequests::default();
let turn_activity = Arc::new(Notify::new());
let reader_process = Arc::clone(&process);
let reader_tx = tx.clone();
let reader_pending = pending.clone();
let reader_session_state = Arc::clone(&session_state);
let reader_pending_host_requests = pending_host_requests.clone();
let reader_turn_activity = Arc::clone(&turn_activity);
let permission_request_deadline = options.permission_request_deadline;
let reader_task = tokio::task::spawn_blocking(move || {
acp_reader_loop(
reader_process,
reader_tx,
reader_pending,
handler,
reader_session_state,
reader_pending_host_requests,
permission_request_deadline,
reader_turn_activity,
);
});
let acp_session_id = Arc::new(tokio::sync::Mutex::new(None::<String>));
let provider_reported_session_id = Arc::new(tokio::sync::Mutex::new(None::<String>));
let mut session = Self {
local_session_id: local_session_id.clone(),
acp_session_id: Arc::clone(&acp_session_id),
provider_reported_session_id: Arc::clone(&provider_reported_session_id),
tool,
tx: tx.clone(),
process,
pending,
id_gen,
reader_task,
prompt_timeout: options.prompt_timeout,
prompt_idle_timeout: options.prompt_idle_timeout,
turn_activity,
host_policy: options.host_policy,
agent_caps: AgentCapabilities::default(),
session_state,
pending_host_requests,
};
let init_params = InitializeParams {
protocol_version: 1,
client_capabilities: options.host_policy.client_capabilities(),
client_info: ClientInfo {
name: "gate4agent",
title: Some("Gate4Agent"),
version: env!("CARGO_PKG_VERSION"),
},
};
let caps: AgentCapabilities = session
.rpc_call_typed("initialize", json!(init_params), options.handshake_timeout, true)
.await
.map_err(|e| map_handshake_error("initialize", e))?;
session.agent_caps = caps;
let new_params = SessionNewParams {
cwd: working_dir.to_str().unwrap_or(".").to_string(),
mcp_servers: options.mcp_servers.clone(),
additional_directories: options.additional_directories.clone(),
};
let new_result: SessionLoadResult = session
.rpc_call_typed("session/new", json!(new_params), options.handshake_timeout, false)
.await
.map_err(|e| map_handshake_error("session/new", e))?;
let acp_sid = if new_result.session_id.is_empty() {
local_session_id.clone()
} else {
new_result.session_id.clone()
};
{
let mut guard = acp_session_id.lock().await;
*guard = Some(acp_sid.clone());
}
{
let mut guard = provider_reported_session_id.lock().await;
*guard = (!new_result.session_id.is_empty()).then(|| new_result.session_id.clone());
}
{
let mut state = session.state();
*state = SessionState::from_handshake(&new_result);
}
let _ = tx.send(AgentEvent::SessionStart {
session_id: acp_sid,
model: "".to_string(),
tools: vec![],
});
Ok(session)
}
pub async fn prompt(&self, text: &str) -> Result<(), AcpError> {
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionPromptParams {
session_id,
prompt: vec![ContentBlock::Text { text: text.to_owned() }],
};
let result = self
.rpc_call("session/prompt", Some(json!(params)), self.prompt_timeout)
.await?;
emit_prompt_result(&self.tx, &result);
Ok(())
}
pub async fn start_prompt(&self, text: &str) -> Result<(), AcpError> {
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionPromptParams {
session_id: session_id.clone(),
prompt: vec![ContentBlock::Text { text: text.to_owned() }],
};
let id = self.id_gen.next();
let request = RpcRequest::new(id.clone(), "session/prompt", Some(json!(params)));
let line = serde_json::to_string(&request).map_err(|source| AcpError::Json { source })?;
let receiver = self.pending.register(id.clone());
if let Err(error) = self.write_line(line).await {
self.pending.remove(&id);
return Err(error);
}
tokio::spawn(run_prompt_watchdog(
self.tx.clone(),
self.pending.clone(),
id,
receiver,
Arc::clone(&self.turn_activity),
self.prompt_idle_timeout,
Arc::clone(&self.process),
session_id,
));
Ok(())
}
pub async fn cancel(&self) -> Result<(), AcpError> {
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionCancelParams { session_id };
self.notify("session/cancel", Some(json!(params))).await
}
pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
self.tx.subscribe()
}
pub fn session_id(&self) -> &str {
&self.local_session_id
}
pub fn tool(&self) -> CliTool {
self.tool
}
pub fn process_id(&self) -> Option<u32> {
self.process.lock().ok().map(|guard| guard.process_id())
}
pub fn reader_finished(&self) -> bool {
self.reader_task.is_finished()
}
pub async fn acp_session_id(&self) -> Option<String> {
self.acp_session_id.lock().await.clone()
}
pub async fn provider_reported_session_id(&self) -> Option<String> {
self.provider_reported_session_id.lock().await.clone()
}
pub async fn kill(&self) -> Result<(), AgentError> {
self.reader_task.abort();
let process = Arc::clone(&self.process);
tokio::task::spawn_blocking(move || {
let mut guard = process
.lock()
.map_err(|_| AgentError::Pty("acp process mutex poisoned".into()))?;
guard.kill().map_err(|e| AgentError::Spawn { source: e })
})
.await
.map_err(|_| AgentError::Pty("spawn_blocking panicked".into()))?
}
pub async fn stop(&self, force: bool) -> Result<AcpStopOutcome, AgentError> {
if !force {
{
let process = Arc::clone(&self.process);
let _ = tokio::task::spawn_blocking(move || {
if let Ok(mut guard) = process.lock() {
guard.close_stdin();
}
})
.await;
}
let deadline = Instant::now() + Duration::from_secs(ACP_GRACEFUL_STOP_BOUND_SECS);
loop {
if self.reader_finished() {
return Ok(AcpStopOutcome {
exit_code: Some(acp_exit_code(&self.process)),
forced: false,
});
}
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
self.kill().await?;
Ok(AcpStopOutcome {
exit_code: None,
forced: true,
})
}
pub fn supports_load_session(&self) -> bool {
self.agent_caps.agent_capabilities.load_session
|| self.agent_caps.agent_capabilities.session_capabilities.resume.is_some()
}
pub fn supports_session_list(&self) -> bool {
self.agent_caps.agent_capabilities.session_capabilities.list.is_some()
}
pub fn supports_session_close(&self) -> bool {
self.agent_caps.agent_capabilities.session_capabilities.close.is_some()
}
pub fn supports_session_delete(&self) -> bool {
self.agent_caps.agent_capabilities.session_capabilities.delete.is_some()
}
pub fn supports_session_fork(&self) -> bool {
self.agent_caps.agent_capabilities.session_capabilities.fork.is_some()
}
pub fn supports_additional_directories(&self) -> bool {
self.agent_caps
.agent_capabilities
.session_capabilities
.additional_directories
.is_some()
}
pub fn supports_subagents(&self) -> bool {
self.agent_caps.agent_capabilities.session_capabilities.subagents.is_some()
}
pub fn available_models(&self) -> Vec<SessionModel> {
self.state().models.available_models.clone()
}
pub fn current_model_id(&self) -> Option<String> {
self.state().models.current_model_id.clone()
}
pub async fn list_sessions(&self, cwd: Option<&str>) -> Result<Vec<SessionSummary>, AcpError> {
if !self.supports_session_list() {
return Err(AcpError::UnsupportedCapability { capability: "list" });
}
let params = SessionListParams { cwd: cwd.map(str::to_owned) };
let result: SessionListResult = self
.rpc_call_typed("session/list", json!(params), self.prompt_timeout, false)
.await?;
Ok(result.sessions)
}
pub async fn close_session(&self) -> Result<(), AcpError> {
if !self.supports_session_close() {
return Err(AcpError::UnsupportedCapability { capability: "close" });
}
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionCloseParams { session_id };
let _: SessionCloseResult = self
.rpc_call_typed("session/close", json!(params), self.prompt_timeout, false)
.await?;
Ok(())
}
pub async fn delete_session(&self, session_id: &str) -> Result<(), AcpError> {
if !self.supports_session_delete() {
return Err(AcpError::UnsupportedCapability { capability: "delete" });
}
let params = SessionDeleteParams { session_id: session_id.to_owned() };
self.rpc_call("session/delete", Some(json!(params)), self.prompt_timeout).await?;
Ok(())
}
pub async fn fork_session(&self, cwd: &str) -> Result<String, AcpError> {
if !self.supports_session_fork() {
return Err(AcpError::UnsupportedCapability { capability: "fork" });
}
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionForkParams { session_id, cwd: cwd.to_owned() };
let result: SessionLoadResult = self
.rpc_call_typed("session/fork", json!(params), self.prompt_timeout, false)
.await?;
Ok(result.session_id)
}
pub async fn load_session(&self, prior_session_id: &str) -> Result<(), AcpError> {
if !self.supports_load_session() {
return Err(AcpError::HandshakeFailed {
message: "agent does not support loadSession".to_string(),
});
}
let params = SessionLoadParams { session_id: prior_session_id.to_owned() };
let result: SessionLoadResult = self
.rpc_call_typed("session/load", json!(params), self.prompt_timeout, false)
.await?;
let new_sid = if result.session_id.is_empty() {
prior_session_id.to_owned()
} else {
result.session_id.clone()
};
{
let mut guard = self.acp_session_id.lock().await;
*guard = Some(new_sid);
}
{
let mut state = self.state();
*state = SessionState::from_handshake(&result);
}
Ok(())
}
pub fn available_modes(&self) -> Vec<SessionMode> {
self.state().modes.available_modes.clone()
}
pub fn current_mode_id(&self) -> Option<String> {
self.state().modes.current_mode_id.clone()
}
pub fn available_commands(&self) -> Vec<AvailableCommand> {
self.state().available_commands.clone()
}
pub fn config_options(&self) -> Vec<SessionConfigOption> {
self.state().config_options.clone()
}
pub fn usage(&self) -> Option<SessionUsage> {
self.state().usage.clone()
}
pub fn session_title(&self) -> Option<String> {
self.state().title.clone()
}
pub async fn set_mode(&self, mode_id: &str) -> Result<(), AcpError> {
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionSetModeParams { session_id, mode_id: mode_id.to_owned() };
self.rpc_call("session/set_mode", Some(json!(params)), self.prompt_timeout)
.await?;
self.state().modes.current_mode_id = Some(mode_id.to_owned());
let _ = self.tx.send(AgentEvent::ModeChanged { mode_id: mode_id.to_owned() });
Ok(())
}
pub async fn set_config_option(&self, option_id: &str, value: Value) -> Result<(), AcpError> {
let session_id = {
let guard = self.acp_session_id.lock().await;
guard.clone().ok_or(AcpError::NoSession)?
};
let params = SessionSetConfigOptionParams {
session_id,
option_id: option_id.to_owned(),
value: value.clone(),
};
self.rpc_call("session/set_config_option", Some(json!(params)), self.prompt_timeout)
.await?;
let mut state = self.state();
if let Some(option) = state.config_options.iter_mut().find(|o| o.id == option_id) {
option.value = value;
}
Ok(())
}
pub fn resolve_pending_request_as(
&self,
id: &RpcId,
choice: OperatorPermissionChoice,
) -> Result<(), PendingRequestError> {
let request = self
.pending_host_requests
.remove(id)
.ok_or_else(|| PendingRequestError::NotFound { id: id.clone() })?;
let preference: &[PermissionOptionKind] = match choice {
OperatorPermissionChoice::Approve => {
&[PermissionOptionKind::AllowOnce, PermissionOptionKind::AllowAlways]
}
OperatorPermissionChoice::Reject => {
&[PermissionOptionKind::RejectOnce, PermissionOptionKind::RejectAlways]
}
};
let outcome = super::host::select_offered_option(&request.params.options, preference);
self.write_pending_response(id, &request.params, outcome, PendingRequestResolution::Answered);
Ok(())
}
pub fn resolve_pending_request(
&self,
id: &RpcId,
option: Option<PermissionOption>,
) -> Result<(), PendingRequestError> {
let request = self
.pending_host_requests
.remove(id)
.ok_or_else(|| PendingRequestError::NotFound { id: id.clone() })?;
let outcome = match option {
Some(option) => PermissionOutcome::Selected { option_id: option.option_id },
None => PermissionOutcome::Cancelled,
};
self.write_pending_response(id, &request.params, outcome, PendingRequestResolution::Answered);
Ok(())
}
pub fn expire_deadlines(&self) {
let expired = self.pending_host_requests.take_expired(Instant::now());
for (id, request) in expired {
let outcome = self
.host_policy
.select_permission_option(&request.params.tool_call, &request.params.options);
self.write_pending_response(&id, &request.params, outcome, PendingRequestResolution::TimedOut);
}
}
fn write_pending_response(
&self,
id: &RpcId,
params: &PermissionRequestParams,
outcome: PermissionOutcome,
resolution: PendingRequestResolution,
) {
let value = serde_json::to_value(&outcome).unwrap_or(Value::Null);
let response = RpcResponse::success(id.clone(), value);
if let Ok(json) = serde_json::to_string(&response) {
write_line_to_process(&self.process, &format!("{}\n", json));
}
let by = match resolution {
PendingRequestResolution::Answered => HostDecisionAuthority::Operator,
PendingRequestResolution::TimedOut => HostDecisionAuthority::DeadlinePolicy,
};
let granted = permission_outcome_grants(&outcome, ¶ms.options);
let decision = if granted {
HostRequestDecision::Granted { by }
} else {
HostRequestDecision::Denied { by }
};
let _ = self.tx.send(AgentEvent::RpcIncomingRequest {
id: id.clone(),
method: "session/request_permission".to_owned(),
params: Some(serde_json::to_value(params).unwrap_or(Value::Null)),
decision,
outcome: HostRequestOutcome::Executed,
reason: None,
});
}
fn state(&self) -> MutexGuard<'_, SessionState> {
self.session_state.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
async fn rpc_call_typed<T: serde::de::DeserializeOwned>(
&self,
method: &str,
params: Value,
timeout: Duration,
_id_zero: bool,
) -> Result<T, AcpError> {
let raw = self.rpc_call(method, Some(params), timeout).await?;
serde_json::from_value(raw).map_err(|e| AcpError::Json { source: e })
}
async fn rpc_call(
&self,
method: &str,
params: Option<Value>,
timeout: Duration,
) -> Result<Value, AcpError> {
let id = self.id_gen.next();
let request = RpcRequest::new(id.clone(), method, params);
let line = serde_json::to_string(&request).map_err(|e| AcpError::Json { source: e })?;
let rx = self.pending.register(id.clone());
if let Err(error) = self.write_line(line).await {
self.pending.remove(&id);
return Err(error);
}
tokio::time::timeout(timeout, rx)
.await
.map_err(|_| AcpError::Timeout {
method: method.to_owned(),
})?
.map_err(|_| AcpError::SessionClosed)?
.map_err(AcpError::Agent)
}
async fn notify(&self, method: &str, params: Option<Value>) -> Result<(), AcpError> {
let notif = RpcNotification {
jsonrpc: "2.0".into(),
method: method.into(),
params,
};
let line = serde_json::to_string(¬if).map_err(|e| AcpError::Json { source: e })?;
self.write_line(line).await
}
async fn write_line(&self, line: String) -> Result<(), AcpError> {
let process = Arc::clone(&self.process);
tokio::task::spawn_blocking(move || {
let mut guard = process.lock().map_err(|_| AcpError::Write {
source: std::io::Error::new(std::io::ErrorKind::Other, "mutex poisoned"),
})?;
guard
.write_line(&line)
.map_err(|e| AcpError::Write { source: e })
})
.await
.map_err(|_| AcpError::Write {
source: std::io::Error::new(std::io::ErrorKind::Other, "spawn_blocking panicked"),
})?
}
}
impl Drop for AcpSession {
fn drop(&mut self) {
if self.reader_task.is_finished() {
return;
}
let mut guard = match self.process.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let _ = guard.kill();
}
}
fn map_handshake_error(step: &'static str, error: AcpError) -> AcpError {
match error {
AcpError::Timeout { .. } => AcpError::HandshakeTimeout { step },
AcpError::Agent(rpc_err)
if rpc_err.code == crate::rpc::message::RpcError::AUTHENTICATION_REQUIRED =>
{
let vendor_message = rpc_err
.data
.as_ref()
.and_then(Value::as_str)
.unwrap_or(&rpc_err.message)
.to_owned();
AcpError::AuthenticationRequired { vendor_message }
}
AcpError::Agent(rpc_err) => AcpError::HandshakeFailed {
message: rpc_err.to_string(),
},
other => other,
}
}
fn generate_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("acp-{:x}", t)
}
fn acp_exit_code(process: &Arc<Mutex<AcpProcess>>) -> i32 {
process
.lock()
.ok()
.map(|mut guard| guard.exit_code())
.unwrap_or(0)
}
fn permission_outcome_grants(outcome: &PermissionOutcome, options: &[PermissionOption]) -> bool {
let PermissionOutcome::Selected { option_id } = outcome else {
return false;
};
options
.iter()
.find(|option| &option.option_id == option_id)
.is_some_and(|option| {
matches!(option.kind, PermissionOptionKind::AllowOnce | PermissionOptionKind::AllowAlways)
})
}
async fn run_prompt_watchdog(
tx: broadcast::Sender<AgentEvent>,
pending: PendingRequests,
id: RpcId,
receiver: tokio::sync::oneshot::Receiver<RpcResult>,
turn_activity: Arc<Notify>,
idle_timeout: Duration,
process: Arc<Mutex<AcpProcess>>,
session_id: String,
) {
tokio::pin!(receiver);
loop {
tokio::select! {
biased;
result = &mut receiver => {
match result {
Ok(Ok(result)) => emit_prompt_result(&tx, &result),
Ok(Err(error)) => report_agent_rpc_error(&tx, &error),
Err(_) => interrupt_turn(
&tx,
"ACP session closed while awaiting prompt response".to_owned(),
),
}
return;
}
_ = turn_activity.notified() => {
continue;
}
_ = tokio::time::sleep(idle_timeout) => {
send_cancel_notification(&process, session_id);
pending.remove(&id);
interrupt_turn(
&tx,
format!(
"session/prompt idle for {idle_timeout:?} with no activity from the agent -- turn cancelled"
),
);
return;
}
}
}
}
fn send_cancel_notification(process: &Arc<Mutex<AcpProcess>>, session_id: String) {
let notif = RpcNotification {
jsonrpc: "2.0".into(),
method: "session/cancel".into(),
params: Some(json!(SessionCancelParams { session_id })),
};
if let Ok(json) = serde_json::to_string(¬if) {
write_line_to_process(process, &format!("{}\n", json));
}
}
fn interrupt_turn(tx: &broadcast::Sender<AgentEvent>, reason: String) {
let _ = tx.send(AgentEvent::Error { message: reason.clone() });
let _ = tx.send(AgentEvent::TurnInterrupted { reason });
}
fn report_agent_rpc_error(tx: &broadcast::Sender<AgentEvent>, error: &crate::rpc::message::RpcError) {
let reason = error.to_string();
let _ = tx.send(AgentEvent::Error { message: reason.clone() });
let _ = tx.send(AgentEvent::TurnInterrupted { reason: reason.clone() });
let (message, vendor_code) = extract_rpc_error_detail(error);
let _ = tx.send(AgentEvent::SessionEnd {
result: reason,
cost_usd: None,
is_error: true,
stop_reason: Some(StopReason::ProviderError {
code: error.code,
message,
vendor_code,
}),
});
}
fn extract_rpc_error_detail(error: &crate::rpc::message::RpcError) -> (String, Option<String>) {
let Some(data) = error.data.as_ref() else {
return (error.message.clone(), None);
};
let message = data
.get("message")
.and_then(Value::as_str)
.or_else(|| data.as_str())
.unwrap_or(&error.message);
let vendor_code = data
.get("codexErrorInfo")
.and_then(Value::as_str)
.or_else(|| {
data.as_object().and_then(|obj| {
obj.iter()
.find(|(key, value)| key.as_str() != "message" && value.is_string())
.and_then(|(_, value)| value.as_str())
})
});
(
crate::utils::truncate_str(message, gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES)
.to_owned(),
vendor_code
.map(|s| crate::utils::truncate_str(s, gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES).to_owned()),
)
}
fn emit_prompt_result(tx: &broadcast::Sender<AgentEvent>, result: &Value) {
let parsed: SessionPromptResult = serde_json::from_value(result.clone()).unwrap_or_default();
let stop_reason = parsed.stop_reason.unwrap_or(StopReason::EndTurn);
let is_error = stop_reason.is_refusal();
let stop_reason_text = stop_reason.as_wire_str().to_owned();
let (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens) =
if let Some(usage) = parsed.usage {
(
usage.input_tokens.unwrap_or(0),
usage.output_tokens.unwrap_or(0),
usage.cached_read_tokens.unwrap_or(0),
usage.cached_write_tokens.unwrap_or(0),
0,
)
} else if let Some(meta) = parsed.meta {
(
meta.input_tokens.unwrap_or(0),
meta.output_tokens.unwrap_or(0),
meta.cached_read_tokens.unwrap_or(0),
0,
meta.reasoning_tokens.unwrap_or(0),
)
} else {
let (input, output) = extract_token_usage(result);
(input, output, 0, 0, 0)
};
let _ = tx.send(AgentEvent::TurnComplete {
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
context_window: None,
is_cumulative: false,
});
let _ = tx.send(AgentEvent::SessionEnd {
result: stop_reason_text,
cost_usd: None,
is_error,
stop_reason: Some(stop_reason),
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_error_display_messages() {
let e = AcpError::HandshakeTimeout { step: "initialize" };
assert!(e.to_string().contains("initialize"));
let e = AcpError::Timeout { method: "session/prompt".into() };
assert!(e.to_string().contains("session/prompt"));
let e = AcpError::NoSession;
assert!(!e.to_string().is_empty());
let e = AcpError::SessionClosed;
assert!(!e.to_string().is_empty());
let e = AcpError::AuthenticationRequired {
vendor_message: "API key required".to_owned(),
};
assert!(e.to_string().contains("API key required"));
}
#[test]
fn map_handshake_error_recognizes_the_authentication_signature() {
use crate::rpc::message::RpcError;
let rpc_err = RpcError {
code: RpcError::AUTHENTICATION_REQUIRED,
message: "acp process exited (code=1); stderr: \u{274c} Error: API key required."
.to_owned(),
data: Some(Value::String("\u{274c} Error: API key required.".to_owned())),
};
match map_handshake_error("initialize", AcpError::Agent(rpc_err)) {
AcpError::AuthenticationRequired { vendor_message } => {
assert_eq!(vendor_message, "\u{274c} Error: API key required.");
}
other => panic!("expected AuthenticationRequired, got {other:?}"),
}
}
#[test]
fn map_handshake_error_keeps_other_rpc_failures_generic() {
use crate::rpc::message::RpcError;
let rpc_err = RpcError::internal("acp process exited (code=1)");
match map_handshake_error("session/new", AcpError::Agent(rpc_err)) {
AcpError::HandshakeFailed { message } => {
assert!(message.contains("acp process exited"));
}
other => panic!("expected HandshakeFailed, got {other:?}"),
}
}
#[test]
fn map_handshake_error_maps_timeout_with_the_given_step() {
let error = AcpError::Timeout {
method: "initialize".into(),
};
match map_handshake_error("initialize", error) {
AcpError::HandshakeTimeout { step } => assert_eq!(step, "initialize"),
other => panic!("expected HandshakeTimeout, got {other:?}"),
}
}
#[test]
fn acp_session_options_default_compiles() {
let opts = AcpSessionOptions::default();
assert_eq!(opts.channel_capacity, 256);
assert_eq!(opts.handshake_timeout, Duration::from_secs(30));
assert_eq!(opts.prompt_timeout, Duration::from_secs(120));
assert_eq!(opts.prompt_idle_timeout, Duration::from_secs(600));
assert_eq!(opts.host_policy, HostPolicy::Auto);
assert!(opts.approval_level_args.is_empty());
assert!(opts.additional_directories.is_empty());
assert!(opts.mcp_servers.is_empty());
assert!(!opts.defer_permission_requests, "deferral must default to off");
assert_eq!(opts.permission_request_deadline, Duration::from_secs(300));
}
fn fake_permission_request(session_id: &str) -> PermissionRequestParams {
use crate::acp::protocol::PermissionToolCall;
PermissionRequestParams {
session_id: session_id.to_owned(),
tool_call: PermissionToolCall::default(),
options: vec![
PermissionOption { option_id: "ao".to_owned(), name: "Allow once".to_owned(), kind: PermissionOptionKind::AllowOnce },
PermissionOption { option_id: "ro".to_owned(), name: "Reject once".to_owned(), kind: PermissionOptionKind::RejectOnce },
],
}
}
#[test]
fn pending_host_requests_insert_remove_roundtrip() {
let map = PendingHostRequests::default();
let id = RpcId::Number(1);
map.insert(
id.clone(),
PendingPermissionRequest {
params: fake_permission_request("s1"),
deadline: Instant::now() + Duration::from_secs(60),
},
);
let removed = map.remove(&id).expect("was inserted");
assert_eq!(removed.params.session_id, "s1");
assert!(map.remove(&id).is_none(), "removing twice must not resurrect the entry");
}
#[test]
fn pending_host_requests_take_expired_only_removes_past_deadline() {
let map = PendingHostRequests::default();
let now = Instant::now();
let expired_id = RpcId::Number(1);
let live_id = RpcId::Number(2);
map.insert(
expired_id.clone(),
PendingPermissionRequest { params: fake_permission_request("expired"), deadline: now },
);
map.insert(
live_id.clone(),
PendingPermissionRequest {
params: fake_permission_request("live"),
deadline: now + Duration::from_secs(600),
},
);
let expired = map.take_expired(now);
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].0, expired_id);
assert_eq!(expired[0].1.params.session_id, "expired");
assert!(map.remove(&live_id).is_some(), "the non-expired entry must still be present");
}
#[test]
fn permission_outcome_grants_selected_allow_kinds_only() {
let options = vec![
PermissionOption { option_id: "ao".to_owned(), name: "Allow once".to_owned(), kind: PermissionOptionKind::AllowOnce },
PermissionOption { option_id: "ro".to_owned(), name: "Reject once".to_owned(), kind: PermissionOptionKind::RejectOnce },
];
assert!(permission_outcome_grants(&PermissionOutcome::Selected { option_id: "ao".to_owned() }, &options));
assert!(!permission_outcome_grants(&PermissionOutcome::Selected { option_id: "ro".to_owned() }, &options));
assert!(!permission_outcome_grants(&PermissionOutcome::Cancelled, &options));
}
#[test]
fn supports_load_session_reads_either_the_legacy_bool_or_the_new_capability_flag() {
use super::super::protocol::{AgentCapabilities, AgentCapabilityFlags, SessionCapabilities};
let mut caps = AgentCapabilities::default();
assert!(!fake_session_supports_load(&caps));
caps.agent_capabilities = AgentCapabilityFlags { load_session: true, ..Default::default() };
assert!(fake_session_supports_load(&caps));
caps.agent_capabilities = AgentCapabilityFlags {
load_session: false,
session_capabilities: SessionCapabilities {
resume: Some(serde_json::json!({})),
..Default::default()
},
..Default::default()
};
assert!(fake_session_supports_load(&caps));
fn fake_session_supports_load(caps: &AgentCapabilities) -> bool {
caps.agent_capabilities.load_session
|| caps.agent_capabilities.session_capabilities.resume.is_some()
}
}
#[test]
fn emit_prompt_result_claude_verbatim_populates_cache_tokens() {
let raw: Value = serde_json::from_str(
r#"{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":4,"cachedReadTokens":15320,"cachedWriteTokens":17081,"totalTokens":32407},"_meta":{"quota":{}}}"#,
)
.expect("valid json literal");
let (tx, mut rx) = broadcast::channel(8);
emit_prompt_result(&tx, &raw);
match rx.try_recv().expect("TurnComplete event") {
AgentEvent::TurnComplete {
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
..
} => {
assert_eq!(input_tokens, 2);
assert_eq!(output_tokens, 4);
assert_eq!(cache_read_tokens, 15320);
assert_eq!(cache_write_tokens, 17081);
assert_eq!(reasoning_tokens, 0);
}
other => panic!("expected TurnComplete, got {other:?}"),
}
match rx.try_recv().expect("SessionEnd event") {
AgentEvent::SessionEnd { result, .. } => assert_eq!(result, "end_turn"),
other => panic!("expected SessionEnd, got {other:?}"),
}
}
#[test]
fn emit_prompt_result_grok_verbatim_reads_meta_not_usage() {
let raw: Value = serde_json::from_str(
r#"{"stopReason":"end_turn","_meta":{"sessionId":"01a05e6a-aa4d-7a13-9e9c-2077aa244389","requestId":"97238a1c-461a-4f9a-ba2f-4acc677b762b","promptId":"97238a1c-461a-4f9a-ba2f-4acc677b762b","totalTokens":19885,"modelId":"grok-4.6","inputTokens":19807,"outputTokens":78,"cachedReadTokens":1408,"reasoningTokens":73}}"#,
)
.expect("valid json literal");
let (tx, mut rx) = broadcast::channel(8);
emit_prompt_result(&tx, &raw);
match rx.try_recv().expect("TurnComplete event") {
AgentEvent::TurnComplete {
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
..
} => {
assert_eq!(input_tokens, 19807);
assert_eq!(output_tokens, 78);
assert_eq!(cache_read_tokens, 1408);
assert_eq!(cache_write_tokens, 0);
assert_eq!(reasoning_tokens, 73);
}
other => panic!("expected TurnComplete, got {other:?}"),
}
}
#[test]
fn emit_prompt_result_kimi_and_codex_verbatim_bare_stop_reason_zeroes_usage() {
let raw: Value = serde_json::from_str(r#"{"stopReason":"end_turn"}"#).expect("valid json literal");
let (tx, mut rx) = broadcast::channel(8);
emit_prompt_result(&tx, &raw);
match rx.try_recv().expect("TurnComplete event") {
AgentEvent::TurnComplete { input_tokens, output_tokens, .. } => {
assert_eq!(input_tokens, 0);
assert_eq!(output_tokens, 0);
}
other => panic!("expected TurnComplete, got {other:?}"),
}
match rx.try_recv().expect("SessionEnd event") {
AgentEvent::SessionEnd { result, .. } => assert_eq!(result, "end_turn"),
other => panic!("expected SessionEnd, got {other:?}"),
}
}
#[test]
fn interrupt_turn_emits_both_error_and_turn_interrupted_with_the_same_reason() {
let (tx, mut rx) = broadcast::channel(8);
interrupt_turn(&tx, "session/prompt timed out after 120s".to_owned());
match rx.try_recv().expect("Error event") {
AgentEvent::Error { message } => {
assert_eq!(message, "session/prompt timed out after 120s");
}
other => panic!("expected Error, got {other:?}"),
}
match rx.try_recv().expect("TurnInterrupted event") {
AgentEvent::TurnInterrupted { reason } => {
assert_eq!(reason, "session/prompt timed out after 120s");
}
other => panic!("expected TurnInterrupted, got {other:?}"),
}
assert!(rx.try_recv().is_err(), "no third event should follow");
}
#[test]
fn report_agent_rpc_error_codex_quota_transcript_verbatim() {
use crate::rpc::message::RpcError;
let error = RpcError {
code: RpcError::INTERNAL_ERROR,
message: "Internal error".to_owned(),
data: Some(json!({
"message": "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 6:19 PM.",
"codexErrorInfo": "usageLimitExceeded",
})),
};
let (tx, mut rx) = broadcast::channel(8);
report_agent_rpc_error(&tx, &error);
assert!(matches!(rx.try_recv().unwrap(), AgentEvent::Error { .. }));
assert!(matches!(rx.try_recv().unwrap(), AgentEvent::TurnInterrupted { .. }));
match rx.try_recv().expect("SessionEnd event") {
AgentEvent::SessionEnd { is_error, stop_reason, .. } => {
assert!(is_error);
match stop_reason {
Some(StopReason::ProviderError { code, message, vendor_code }) => {
assert_eq!(code, RpcError::INTERNAL_ERROR);
assert!(message.starts_with("You've hit your usage limit"));
assert_eq!(vendor_code.as_deref(), Some("usageLimitExceeded"));
}
other => panic!("expected StopReason::ProviderError, got {other:?}"),
}
}
other => panic!("expected SessionEnd, got {other:?}"),
}
}
#[cfg(windows)]
const WINDOWS_GRACEFUL_SCRIPT: &str = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
function Write-JsonLine($value) { [Console]::WriteLine(($value | ConvertTo-Json -Compress -Depth 12)) }
$initialize = [Console]::ReadLine() | ConvertFrom-Json
Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;result=@{}}
$newSession = [Console]::ReadLine() | ConvertFrom-Json
Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;result=@{sessionId='fixture-acp-session'}}
while ($true) {
$line = [Console]::ReadLine()
if ($null -eq $line) { exit 0 }
}"#;
#[cfg(windows)]
const WINDOWS_IGNORE_EOF_SCRIPT: &str = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
function Write-JsonLine($value) { [Console]::WriteLine(($value | ConvertTo-Json -Compress -Depth 12)) }
$initialize = [Console]::ReadLine() | ConvertFrom-Json
Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;result=@{}}
$newSession = [Console]::ReadLine() | ConvertFrom-Json
Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;result=@{sessionId='fixture-acp-session'}}
while ($true) {
$line = [Console]::ReadLine()
if ($null -eq $line) { Start-Sleep -Milliseconds 200 }
}"#;
#[cfg(not(windows))]
const UNIX_GRACEFUL_SCRIPT: &str = r#"import json,sys
def read_message():
line=sys.stdin.readline()
if not line: return None
return json.loads(line)
def write_message(message):
print(json.dumps(message),flush=True)
initialize=read_message()
write_message({'jsonrpc':'2.0','id':initialize.get('id'),'result':{}})
new_session=read_message()
write_message({'jsonrpc':'2.0','id':new_session.get('id'),'result':{'sessionId':'fixture-acp-session'}})
while True:
msg=read_message()
if msg is None:
sys.exit(0)"#;
#[cfg(not(windows))]
const UNIX_IGNORE_EOF_SCRIPT: &str = r#"import json,sys,time
def read_message():
line=sys.stdin.readline()
if not line: return None
return json.loads(line)
def write_message(message):
print(json.dumps(message),flush=True)
initialize=read_message()
write_message({'jsonrpc':'2.0','id':initialize.get('id'),'result':{}})
new_session=read_message()
write_message({'jsonrpc':'2.0','id':new_session.get('id'),'result':{'sessionId':'fixture-acp-session'}})
while True:
msg=read_message()
if msg is None:
time.sleep(0.2)"#;
#[cfg(windows)]
fn acp_graceful_exit_launch() -> LaunchSpec {
LaunchSpec {
program: "powershell.exe".to_owned(),
fixed_args: vec![
"-NoLogo".to_owned(),
"-NoProfile".to_owned(),
"-NonInteractive".to_owned(),
"-ExecutionPolicy".to_owned(),
"Bypass".to_owned(),
"-Command".to_owned(),
WINDOWS_GRACEFUL_SCRIPT.to_owned(),
],
}
}
#[cfg(not(windows))]
fn acp_graceful_exit_launch() -> LaunchSpec {
LaunchSpec {
program: "python3".to_owned(),
fixed_args: vec!["-u".to_owned(), "-c".to_owned(), UNIX_GRACEFUL_SCRIPT.to_owned()],
}
}
#[cfg(windows)]
fn acp_ignore_eof_launch() -> LaunchSpec {
LaunchSpec {
program: "powershell.exe".to_owned(),
fixed_args: vec![
"-NoLogo".to_owned(),
"-NoProfile".to_owned(),
"-NonInteractive".to_owned(),
"-ExecutionPolicy".to_owned(),
"Bypass".to_owned(),
"-Command".to_owned(),
WINDOWS_IGNORE_EOF_SCRIPT.to_owned(),
],
}
}
#[cfg(not(windows))]
fn acp_ignore_eof_launch() -> LaunchSpec {
LaunchSpec {
program: "python3".to_owned(),
fixed_args: vec!["-u".to_owned(), "-c".to_owned(), UNIX_IGNORE_EOF_SCRIPT.to_owned()],
}
}
async fn spawn_fixture_acp_session(launch: LaunchSpec) -> AcpSession {
AcpSession::spawn_with_launch(
CliTool::ClaudeCode,
&std::env::current_dir().expect("cwd"),
AcpSessionOptions::default(),
&launch,
)
.await
.expect("fixture ACP handshake must succeed")
}
#[tokio::test]
async fn acp_stop_graceful_reports_the_real_exit_code_for_a_process_that_ends_itself() {
let session = spawn_fixture_acp_session(acp_graceful_exit_launch()).await;
let outcome = session
.stop(false)
.await
.expect("stop(force=false) must succeed for a process that exits on its own");
assert_eq!(outcome.exit_code, Some(0), "outcome: {outcome:?}");
assert!(
!outcome.forced,
"a process that already exited on its own must not be reported as forced"
);
}
#[tokio::test]
async fn acp_stop_graceful_falls_back_to_a_kill_once_the_bound_elapses() {
let session = spawn_fixture_acp_session(acp_ignore_eof_launch()).await;
let outcome = session
.stop(false)
.await
.expect("stop(force=false) must still succeed by falling back to a kill");
assert!(
outcome.forced,
"a process that ignores stdin EOF must be force-killed once the graceful bound elapses"
);
assert!(outcome.exit_code.is_none(), "outcome: {outcome:?}");
}
async fn recv_bounded_by_real_time(
events: &mut broadcast::Receiver<AgentEvent>,
real_bound: Duration,
) -> Option<AgentEvent> {
let (bail_tx, bail_rx) = tokio::sync::oneshot::channel::<()>();
std::thread::spawn(move || {
std::thread::sleep(real_bound);
let _ = bail_tx.send(());
});
tokio::select! {
event = events.recv() => event.ok(),
_ = bail_rx => None,
}
}
#[tokio::test]
async fn start_prompt_watchdog_survives_activity_past_the_old_120s_mark() {
let session = spawn_fixture_acp_session(acp_ignore_eof_launch()).await;
let mut events = session.subscribe();
let id = RpcId::Number(9001);
let receiver = session.pending.register(id.clone());
let turn_activity = Arc::new(Notify::new());
let idle_timeout = Duration::from_secs(20);
tokio::time::pause();
let watchdog = tokio::spawn(run_prompt_watchdog(
session.tx.clone(),
session.pending.clone(),
id.clone(),
receiver,
Arc::clone(&turn_activity),
idle_timeout,
Arc::clone(&session.process),
"fixture-acp-session".to_owned(),
));
tokio::task::yield_now().await;
for _ in 0..9 {
tokio::time::advance(Duration::from_secs(15)).await;
turn_activity.notify_one();
tokio::task::yield_now().await;
}
tokio::time::advance(Duration::from_secs(19)).await;
assert!(
recv_bounded_by_real_time(&mut events, Duration::from_secs(2))
.await
.is_none(),
"activity that keeps resetting the idle window must never interrupt the turn before it elapses"
);
assert_eq!(
session.pending.len(),
1,
"the request must still be pending -- the watchdog must not have fired yet"
);
tokio::time::advance(Duration::from_secs(2)).await;
let fired = recv_bounded_by_real_time(&mut events, Duration::from_secs(5))
.await
.expect(
"the watchdog must be ABLE to fire once its idle window elapses -- otherwise \
the earlier 'nothing fired' assertion would be vacuous",
);
assert!(
matches!(&fired, AgentEvent::Error { message } if message.contains("idle")),
"expected an idle Error event, got {fired:?}"
);
watchdog.abort();
let _ = session.kill().await;
}
#[tokio::test]
async fn start_prompt_watchdog_fires_on_full_silence_and_deregisters_the_pending_request() {
let session = spawn_fixture_acp_session(acp_ignore_eof_launch()).await;
let mut events = session.subscribe();
let id = RpcId::Number(9002);
let receiver = session.pending.register(id.clone());
let turn_activity = Arc::new(Notify::new());
let idle_timeout = Duration::from_secs(10);
tokio::time::pause();
let _watchdog = tokio::spawn(run_prompt_watchdog(
session.tx.clone(),
session.pending.clone(),
id.clone(),
receiver,
Arc::clone(&turn_activity),
idle_timeout,
Arc::clone(&session.process),
"fixture-acp-session".to_owned(),
));
tokio::task::yield_now().await;
tokio::time::advance(idle_timeout + Duration::from_millis(1)).await;
let first_event = recv_bounded_by_real_time(&mut events, Duration::from_secs(5))
.await
.expect("watchdog did not fire even once fully parked after the idle window elapsed");
match first_event {
AgentEvent::Error { message } => {
assert!(message.contains("idle"), "message was: {message}");
}
other => panic!("expected Error, got {other:?}"),
}
match events.recv().await.expect("TurnInterrupted event") {
AgentEvent::TurnInterrupted { reason } => {
assert!(reason.contains("idle"), "reason was: {reason}");
}
other => panic!("expected TurnInterrupted, got {other:?}"),
}
assert!(
session.pending.is_empty(),
"the pending request must be de-registered once the watchdog fires"
);
let _ = session.kill().await;
}
fn process_is_listed(pid: u32) -> bool {
crate::pty::os_process::query_process_tree_rows()
.expect("the OS process table must be readable")
.iter()
.any(|row| row.pid == pid)
}
#[tokio::test]
async fn dropping_a_session_without_kill_reaps_the_child_process() {
let session = spawn_fixture_acp_session(acp_ignore_eof_launch()).await;
let pid = session.process_id().expect("fixture process must report a pid");
assert!(
process_is_listed(pid),
"the probe must see the live fixture (pid={pid}) before the drop, or the check below proves nothing"
);
drop(session);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if !process_is_listed(pid) {
return;
}
if Instant::now() >= deadline {
panic!("dropping the AcpSession did not reap its child process (pid={pid})");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}