use super::{permission, result_collector, update_mapper};
use async_trait::async_trait;
use luft_core::contract::backend::{
AgentBackend, AgentCapabilities, AgentResult, AgentTask, BackendError, RunContext, ToolPolicy,
};
use luft_core::contract::event::EventSender;
#[cfg(feature = "unstable_end_turn_token_usage")]
use luft_core::contract::ids::TokenUsage;
use luft_core::contract::ids::{AgentId, RunId};
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
type AcpTransport =
ByteStreams<Compat<tokio::process::ChildStdin>, Compat<tokio::process::ChildStdout>>;
use agent_client_protocol::schema::{
ContentBlock, InitializeRequest, McpServer, McpServerStdio, NewSessionRequest,
NewSessionResponse, PromptRequest, ProtocolVersion, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionId,
SessionNotification, SetSessionConfigOptionRequest, StopReason, TextContent,
};
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
const POST_SUBMISSION_IDLE: Duration = Duration::from_secs(5);
const STOP_REASON_END_TURN: &str = "EndTurn";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatchdogOutcome {
PreIdleTimeout,
PostSubmissionTimeout,
ChannelClosed,
}
struct SessionState {
acc: Arc<update_mapper::Accumulator>,
stop_holder: Arc<Mutex<Option<String>>>,
events: EventSender,
activity_tx: tokio::sync::mpsc::UnboundedSender<()>,
activity_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
submit_signal: Arc<tokio::sync::Notify>,
run_id: RunId,
agent_id: AgentId,
emit_raw: bool,
policy: Option<ToolPolicy>,
prompt: String,
cwd: PathBuf,
}
#[derive(Debug, Clone)]
pub struct AcpConfig {
pub id: &'static str,
pub binary: PathBuf,
pub acp_args: Vec<String>,
pub log_level: Option<String>,
pub connect_timeout: Duration,
pub emit_raw_events: bool,
pub env_passthrough: Vec<String>,
pub model: Option<String>,
}
impl AcpConfig {
pub const DEFAULT_ENV_PASSTHROUGH: &'static [&'static str] = &[
"PATH",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"PATHEXT",
"USERPROFILE",
"HOME",
"USER",
"USERNAME",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"SHELL",
"APPDATA",
"LOCALAPPDATA",
"ProgramFiles",
"ProgramFiles(x86)",
];
}
impl Default for AcpConfig {
fn default() -> Self {
Self {
id: "opencode",
binary: PathBuf::from("opencode"),
acp_args: vec!["acp".to_string()],
log_level: None,
connect_timeout: Duration::from_secs(10),
emit_raw_events: true,
env_passthrough: Self::DEFAULT_ENV_PASSTHROUGH
.iter()
.map(|s| s.to_string())
.collect(),
model: None,
}
}
}
pub struct AcpAdapter {
config: AcpConfig,
}
impl AcpAdapter {
pub fn new(config: AcpConfig) -> Self {
Self { config }
}
pub fn default_opencode() -> Self {
Self::new(AcpConfig::default())
}
pub fn config(&self) -> &AcpConfig {
&self.config
}
}
#[async_trait]
impl AgentBackend for AcpAdapter {
fn id(&self) -> &'static str {
self.config.id
}
fn capabilities(&self) -> AgentCapabilities {
AgentCapabilities {
streaming: true,
mcp_injection: true,
structured_output: true,
models: vec![],
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
async fn run(&self, task: AgentTask, ctx: RunContext) -> Result<AgentResult, BackendError> {
let config = self.config.clone();
let cancel = ctx.cancel.clone();
let events = ctx.events.clone();
let run_id = ctx.run_id;
let handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| BackendError::Execution(format!("acp runtime: {e}")))?;
let local = tokio::task::LocalSet::new();
local.block_on(&rt, run_acp_session(config, task, run_id, cancel, events))
});
handle
.await
.map_err(|e| BackendError::Execution(format!("acp task join: {e}")))?
}
}
#[tracing::instrument(
name = "backend",
skip_all,
fields(run_id = %run_id, agent_id = %task.agent_id, backend = "opencode")
)]
async fn run_acp_session(
config: AcpConfig,
task: AgentTask,
run_id: RunId,
cancel: tokio_util::sync::CancellationToken,
events: EventSender,
) -> Result<AgentResult, BackendError> {
let (mut child, transport) = spawn_agent(&config)?;
let (activity_tx, activity_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit_signal = Arc::new(tokio::sync::Notify::new());
let mut state = SessionState {
acc: Arc::new(update_mapper::Accumulator::new()),
stop_holder: Arc::new(Mutex::new(None)),
events: events.clone(),
activity_tx,
activity_rx,
submit_signal: submit_signal.clone(),
run_id,
agent_id: task.agent_id,
emit_raw: config.emit_raw_events,
policy: task.allowlist.clone(),
prompt: task.prompt.clone(),
cwd: std::fs::canonicalize(&task.workdir).unwrap_or_else(|_| task.workdir.clone()),
};
let schema_guard = prepare_schema_mcp(task.output_schema.as_ref())?;
let schema_file_path = schema_guard
.as_ref()
.map(|g| g.0.path().to_string_lossy().into_owned());
let conn_fut = drive_connection(&state, transport, schema_file_path, config.model.clone());
let idle_timeout = task.timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT);
let outcome = tokio::select! {
r = conn_fut => r,
_ = cancel.cancelled() => {
tracing::debug!("ACP session cancelled");
let _ = child.start_kill();
return Err(BackendError::Cancelled);
}
res = idle_watchdog(
idle_timeout,
POST_SUBMISSION_IDLE,
&mut state.activity_rx,
state.submit_signal.clone(),
) => {
handle_watchdog_outcome(res, &mut child, &state.stop_holder, idle_timeout)?;
Ok(())
}
};
let _ = child.start_kill();
outcome.map_err(classify_protocol_error)?;
Ok(collect_session_result(&task, &state))
}
fn spawn_agent(config: &AcpConfig) -> Result<(tokio::process::Child, AcpTransport), BackendError> {
let mut cmd = tokio::process::Command::new(&config.binary);
cmd.args(&config.acp_args);
if let Some(level) = &config.log_level {
cmd.arg("--log-level").arg(level);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
cmd.env_clear();
for name in &config.env_passthrough {
if let Ok(value) = std::env::var(name) {
cmd.env(name, value);
}
}
let mut child = cmd.spawn().map_err(|e| {
tracing::error!(binary = %config.binary.display(), error = %e, "failed to spawn ACP backend");
BackendError::Spawn(format!("failed to spawn {}: {e}", config.binary.display()))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| BackendError::Spawn("no child stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| BackendError::Spawn("no child stdout".into()))?;
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
Ok((child, transport))
}
fn prepare_schema_mcp(
schema: Option<&serde_json::Value>,
) -> Result<Option<SchemaFileGuard>, BackendError> {
let Some(schema) = schema else {
return Ok(None);
};
let schema_json = serde_json::to_string(schema)
.map_err(|e| BackendError::Execution(format!("schema serialize: {e}")))?;
let schema_file = tempfile::NamedTempFile::new()
.map_err(|e| BackendError::Execution(format!("schema temp file: {e}")))?;
std::fs::write(&schema_file, &schema_json)
.map_err(|e| BackendError::Execution(format!("schema temp write: {e}")))?;
let path = schema_file.path().to_string_lossy().into_owned();
tracing::debug!(schema_file = %path, "prepared MCP structured-output server");
Ok(Some(SchemaFileGuard(schema_file)))
}
struct SchemaFileGuard(tempfile::NamedTempFile);
fn drive_connection(
state: &SessionState,
transport: AcpTransport,
schema_file_path: Option<String>,
model: Option<String>,
) -> impl std::future::Future<Output = Result<(), agent_client_protocol::Error>> {
let acc = state.acc.clone();
let events = state.events.clone();
let stop_holder = state.stop_holder.clone();
let activity_tx = state.activity_tx.clone();
let submit_signal = state.submit_signal.clone();
let run_id = state.run_id;
let agent_id = state.agent_id;
let emit_raw = state.emit_raw;
let policy = state.policy.clone();
let acc_for_prompt = acc.clone();
let stop_holder_for_prompt = stop_holder.clone();
let cwd = state.cwd.clone();
let prompt = state.prompt.clone();
async move {
Client
.builder()
.name("luft")
.on_receive_notification(
{
let acc = acc.clone();
let events = events.clone();
let activity_tx = activity_tx.clone();
let submit_signal = submit_signal.clone();
move |n: SessionNotification, _cx: ConnectionTo<Agent>| {
let acc = acc.clone();
let events = events.clone();
let activity_tx = activity_tx.clone();
let submit_signal = submit_signal.clone();
async move {
handle_session_update(
n,
&acc,
&events,
&activity_tx,
&submit_signal,
run_id,
agent_id,
emit_raw,
);
Ok(())
}
}
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
{
let policy = policy.clone();
move |req: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
_conn: ConnectionTo<Agent>| {
let policy = policy.clone();
async move { decide_permission(req, responder, policy).await }
}
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, move |conn: ConnectionTo<Agent>| {
let acc_for_prompt = acc_for_prompt.clone();
let stop_holder_for_prompt = stop_holder_for_prompt.clone();
let model = model.clone();
async move {
run_handshake_and_prompt(
&conn,
&cwd,
schema_file_path.as_deref(),
model.as_deref(),
&prompt,
&acc_for_prompt,
&stop_holder_for_prompt,
)
.await
}
})
.await
}
}
#[allow(clippy::too_many_arguments)]
fn handle_session_update(
n: SessionNotification,
acc: &Arc<update_mapper::Accumulator>,
events: &EventSender,
activity_tx: &tokio::sync::mpsc::UnboundedSender<()>,
submit_signal: &Arc<tokio::sync::Notify>,
run_id: RunId,
agent_id: AgentId,
emit_raw: bool,
) {
let _ = activity_tx.send(());
let kind = serde_json::to_value(&n.update)
.ok()
.and_then(|v| {
v.get("sessionUpdate")
.and_then(|v| v.as_str())
.map(String::from)
})
.unwrap_or_else(|| "unknown".to_string());
tracing::debug!(%kind, "ACP session/update");
let was_submitted = acc.structured_output.lock().unwrap().is_some();
update_mapper::handle_update(&n.update, run_id, agent_id, acc, events, emit_raw);
if !was_submitted && acc.structured_output.lock().unwrap().is_some() {
submit_signal.notify_one();
tracing::debug!(
"ACP structured_output captured; watchdog switching to post-submission mode"
);
}
}
async fn decide_permission(
req: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
policy: Option<ToolPolicy>,
) -> Result<(), agent_client_protocol::Error> {
let inputs = permission::extract_inputs(&req);
let approve = matches!(
permission::decide(policy.as_ref(), &inputs),
permission::Decision::Approve
);
tracing::debug!(
approve,
options = req.options.len(),
"ACP permission request"
);
let outcome = match (approve, req.options.first()) {
(true, Some(opt)) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
opt.option_id.clone(),
)),
_ => RequestPermissionOutcome::Cancelled,
};
responder.respond(RequestPermissionResponse::new(outcome))
}
async fn run_handshake_and_prompt(
conn: &ConnectionTo<Agent>,
cwd: &std::path::Path,
schema_file_path: Option<&str>,
model: Option<&str>,
prompt: &str,
acc: &Arc<update_mapper::Accumulator>,
stop_holder: &Arc<Mutex<Option<String>>>,
) -> Result<(), agent_client_protocol::Error> {
tracing::debug!("ACP handshake: initialize");
conn.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
tracing::debug!("ACP handshake: session/new");
let ns = session_new(conn, cwd.to_path_buf(), schema_file_path).await?;
if let Some(model_name) = model {
validate_and_set_model(conn, &ns, model_name).await?;
}
tracing::debug!("ACP handshake: session/prompt");
let pr = send_prompt(conn, ns.session_id, prompt.to_string()).await?;
record_prompt_result(&pr, stop_holder, acc);
Ok(())
}
async fn session_new(
conn: &ConnectionTo<Agent>,
cwd: PathBuf,
schema_file_path: Option<&str>,
) -> Result<NewSessionResponse, agent_client_protocol::Error> {
let req = NewSessionRequest::new(cwd);
let req = match schema_file_path {
Some(sf) => {
let luft_bin =
std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("luft"));
let mcp = McpServerStdio::new("luft-structured-output", luft_bin).args(vec![
"mcp-structured-output".to_string(),
"--schema-file".to_string(),
sf.to_string(),
]);
req.mcp_servers(vec![McpServer::Stdio(mcp)])
}
None => req,
};
conn.send_request(req).block_task().await
}
async fn validate_and_set_model(
conn: &ConnectionTo<Agent>,
ns: &NewSessionResponse,
model_name: &str,
) -> Result<(), agent_client_protocol::Error> {
let config_options = match ns.config_options.as_ref() {
Some(opts) => opts,
None => {
tracing::debug!("ACP: agent does not advertise config_options");
return Ok(());
}
};
let model_option = match config_options
.iter()
.find(|opt| opt.category.as_ref() == Some(&SessionConfigOptionCategory::Model))
{
Some(o) => o,
None => {
tracing::debug!("ACP: agent does not support model selection");
return Ok(());
}
};
let select = match &model_option.kind {
SessionConfigKind::Select(s) => s,
_ => {
tracing::debug!("ACP: model option is not a Select kind");
return Ok(());
}
};
let valid = match &select.options {
SessionConfigSelectOptions::Ungrouped(opts) => {
opts.iter().any(|o| o.value.0.as_ref() == model_name)
}
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.any(|g| g.options.iter().any(|o| o.value.0.as_ref() == model_name)),
_ => false,
};
if valid {
tracing::debug!(model = %model_name, "ACP: setting session model");
let req = SetSessionConfigOptionRequest::new(
ns.session_id.clone(),
model_option.id.clone(),
model_name.to_string(),
);
conn.send_request(req).block_task().await?;
} else {
tracing::warn!(
model = %model_name,
"ACP: requested model not available, using agent default"
);
}
Ok(())
}
async fn send_prompt(
conn: &ConnectionTo<Agent>,
session_id: SessionId,
prompt: String,
) -> Result<agent_client_protocol::schema::PromptResponse, agent_client_protocol::Error> {
conn.send_request(PromptRequest::new(
session_id,
vec![ContentBlock::Text(TextContent::new(prompt))],
))
.block_task()
.await
}
fn record_prompt_result(
pr: &agent_client_protocol::schema::PromptResponse,
stop_holder: &Arc<Mutex<Option<String>>>,
#[cfg_attr(
not(feature = "unstable_end_turn_token_usage"),
allow(unused_variables)
)]
acc: &Arc<update_mapper::Accumulator>,
) {
tracing::debug!(stop_reason = ?pr.stop_reason, "ACP prompt complete");
*stop_holder.lock().unwrap() = Some(stop_reason_as_str(&pr.stop_reason));
#[cfg(feature = "unstable_end_turn_token_usage")]
{
if let Some(u) = pr.usage.as_ref() {
tracing::debug!(
input = u.input_tokens,
output = u.output_tokens,
total = u.total_tokens,
"ACP prompt usage"
);
*acc.tokens.lock().unwrap() = TokenUsage {
input: u.input_tokens,
output: u.output_tokens,
cache_read: u.cached_read_tokens.unwrap_or(0),
cache_write: u.cached_write_tokens.unwrap_or(0),
};
}
}
}
fn stop_reason_as_str(r: &StopReason) -> String {
match r {
StopReason::EndTurn => STOP_REASON_END_TURN.to_string(),
StopReason::MaxTokens => "MaxTokens".to_string(),
StopReason::MaxTurnRequests => "MaxTurnRequests".to_string(),
StopReason::Refusal => "Refusal".to_string(),
StopReason::Cancelled => "Cancelled".to_string(),
#[allow(unreachable_patterns)]
other => format!("{other:?}"),
}
}
fn handle_watchdog_outcome(
res: WatchdogOutcome,
child: &mut tokio::process::Child,
stop_holder: &Arc<Mutex<Option<String>>>,
idle_timeout: Duration,
) -> Result<(), BackendError> {
let _ = child.start_kill();
match res {
WatchdogOutcome::PreIdleTimeout => {
tracing::warn!(
idle_timeout_ms = idle_timeout.as_millis() as u64,
"ACP session idle timeout (no protocol activity)"
);
Err(BackendError::Timeout)
}
WatchdogOutcome::ChannelClosed => {
tracing::debug!("ACP activity channel closed");
Err(BackendError::Timeout)
}
WatchdogOutcome::PostSubmissionTimeout => {
tracing::info!(
post_idle_ms = POST_SUBMISSION_IDLE.as_millis() as u64,
"ACP post-submission timeout; treating structured_output as result"
);
let mut guard = stop_holder.lock().unwrap();
if guard.is_none() {
*guard = Some(STOP_REASON_END_TURN.to_string());
}
Ok(())
}
}
}
fn classify_protocol_error(e: agent_client_protocol::Error) -> BackendError {
let s = e.to_string();
if is_connection_closed(&s) {
tracing::warn!("ACP connection closed");
BackendError::Protocol("connection closed".into())
} else {
tracing::error!(error = %s, "ACP protocol error");
BackendError::Protocol(s)
}
}
fn is_connection_closed(s: &str) -> bool {
s.contains("receiver dropped")
|| s.contains("broken pipe")
|| s.contains("unexpected eof")
|| s.contains("connection closed")
}
fn collect_session_result(task: &AgentTask, state: &SessionState) -> AgentResult {
let stop = state.stop_holder.lock().unwrap().take().unwrap_or_default();
let message = std::mem::take(&mut *state.acc.message.lock().unwrap());
let tokens = *state.acc.tokens.lock().unwrap();
let structured = state.acc.structured_output.lock().unwrap().take();
result_collector::collect(task, &stop, message, tokens, structured)
}
async fn idle_watchdog(
pre_idle: Duration,
post_idle: Duration,
activity_rx: &mut tokio::sync::mpsc::UnboundedReceiver<()>,
submit_signal: Arc<tokio::sync::Notify>,
) -> WatchdogOutcome {
let mut submitted = false;
loop {
if submitted {
while activity_rx.try_recv().is_ok() {}
tokio::time::sleep(post_idle).await;
return WatchdogOutcome::PostSubmissionTimeout;
}
tokio::select! {
biased;
_ = submit_signal.notified() => {
submitted = true;
tracing::debug!(
post_idle_ms = post_idle.as_millis() as u64,
"ACP watchdog entered post-submission mode"
);
}
msg = activity_rx.recv() => match msg {
Some(()) => { while activity_rx.try_recv().is_ok() {} }
None => return WatchdogOutcome::ChannelClosed,
},
_ = tokio::time::sleep(pre_idle) => {
return WatchdogOutcome::PreIdleTimeout;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn idle_watchdog_fires_after_idle_period() {
let (_atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
let r = tokio::time::timeout(
Duration::from_millis(500),
idle_watchdog(
Duration::from_millis(50),
Duration::from_millis(50),
&mut arx,
submit,
),
)
.await;
let outcome = r.expect("should fire after idle period");
assert_eq!(outcome, WatchdogOutcome::PreIdleTimeout);
}
#[tokio::test]
async fn idle_watchdog_does_not_fire_with_activity() {
let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
tokio::spawn(async move {
for _ in 0..5 {
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = atx.send(());
}
});
let r = tokio::time::timeout(
Duration::from_millis(80),
idle_watchdog(
Duration::from_millis(50),
Duration::from_millis(50),
&mut arx,
submit,
),
)
.await;
assert!(
r.is_err(),
"should not fire while activity is within idle window"
);
}
#[tokio::test]
async fn idle_watchdog_fires_after_activity_stops() {
let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
let _ = atx.send(());
drop(atx);
let r = tokio::time::timeout(
Duration::from_millis(30),
idle_watchdog(
Duration::from_millis(80),
Duration::from_millis(80),
&mut arx,
submit,
),
)
.await;
let outcome = r.expect("should return immediately when channel closes");
assert_eq!(outcome, WatchdogOutcome::ChannelClosed);
}
#[tokio::test]
async fn idle_watchdog_enters_post_mode_after_submit_signal() {
let (_atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
let submit_h = submit.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
submit_h.notify_one();
});
let r = tokio::time::timeout(
Duration::from_millis(500),
idle_watchdog(
Duration::from_secs(60), Duration::from_millis(50), &mut arx,
submit,
),
)
.await;
let outcome = r.expect("watchdog should return after post_idle");
assert_eq!(outcome, WatchdogOutcome::PostSubmissionTimeout);
}
#[tokio::test]
async fn idle_watchdog_post_mode_is_not_reset_by_activity() {
let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
submit.notify_one();
tokio::spawn(async move {
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = atx.send(());
}
});
let start = std::time::Instant::now();
let r = tokio::time::timeout(
Duration::from_millis(500),
idle_watchdog(
Duration::from_secs(60),
Duration::from_millis(80),
&mut arx,
submit,
),
)
.await;
let outcome = r.expect("watchdog should return after post_idle");
let elapsed = start.elapsed();
assert_eq!(outcome, WatchdogOutcome::PostSubmissionTimeout);
assert!(
elapsed < Duration::from_millis(300),
"post-mode timer was reset by activity: elapsed={elapsed:?}"
);
}
#[tokio::test]
async fn idle_watchdog_pre_mode_resets_on_activity() {
let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
let submit = Arc::new(tokio::sync::Notify::new());
tokio::spawn(async move {
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = atx.send(());
}
});
let r = tokio::time::timeout(
Duration::from_millis(200),
idle_watchdog(
Duration::from_millis(60),
Duration::from_millis(60),
&mut arx,
submit,
),
)
.await;
assert!(
r.is_err(),
"pre-mode should not fire while activity keeps resetting timer"
);
}
#[test]
fn stop_reason_as_str_end_turn_matches_constant() {
assert_eq!(
stop_reason_as_str(&StopReason::EndTurn),
STOP_REASON_END_TURN
);
assert_eq!(stop_reason_as_str(&StopReason::EndTurn), "EndTurn");
}
#[test]
fn stop_reason_as_str_cancelled_contains_cancel() {
assert_eq!(stop_reason_as_str(&StopReason::Cancelled), "Cancelled");
}
#[test]
fn stop_reason_as_str_other_variants_stable() {
assert_eq!(stop_reason_as_str(&StopReason::MaxTokens), "MaxTokens");
assert_eq!(
stop_reason_as_str(&StopReason::MaxTurnRequests),
"MaxTurnRequests"
);
assert_eq!(stop_reason_as_str(&StopReason::Refusal), "Refusal");
}
#[tokio::test]
async fn handle_watchdog_post_submission_synthesizes_end_turn() {
let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let mut child = match tokio::process::Command::new("cmd")
.arg("/C")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(c) => c,
Err(_) => return, };
let r = handle_watchdog_outcome(
WatchdogOutcome::PostSubmissionTimeout,
&mut child,
&stop,
Duration::from_secs(300),
);
assert!(
r.is_ok(),
"post-submission outcome should fall through to collect"
);
assert_eq!(stop.lock().unwrap().as_deref(), Some("EndTurn"));
}
#[tokio::test]
async fn handle_watchdog_post_submission_preserves_existing_stop() {
let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(Some("Cancelled".into())));
let mut child = match tokio::process::Command::new("cmd")
.arg("/C")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(c) => c,
Err(_) => return,
};
let r = handle_watchdog_outcome(
WatchdogOutcome::PostSubmissionTimeout,
&mut child,
&stop,
Duration::from_secs(300),
);
assert!(r.is_ok());
assert_eq!(stop.lock().unwrap().as_deref(), Some("Cancelled"));
}
#[tokio::test]
async fn handle_watchdog_pre_idle_returns_timeout_error() {
let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let mut child = match tokio::process::Command::new("cmd")
.arg("/C")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(c) => c,
Err(_) => return,
};
let r = handle_watchdog_outcome(
WatchdogOutcome::PreIdleTimeout,
&mut child,
&stop,
Duration::from_secs(1),
);
assert!(matches!(r, Err(BackendError::Timeout)));
}
#[tokio::test]
async fn handle_watchdog_channel_closed_returns_timeout_error() {
let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let mut child = match tokio::process::Command::new("cmd")
.arg("/C")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(c) => c,
Err(_) => return,
};
let r = handle_watchdog_outcome(
WatchdogOutcome::ChannelClosed,
&mut child,
&stop,
Duration::from_secs(1),
);
assert!(matches!(r, Err(BackendError::Timeout)));
}
#[test]
fn is_connection_closed_matches_documented_substrings() {
assert!(is_connection_closed("receiver dropped"));
assert!(is_connection_closed("broken pipe"));
assert!(is_connection_closed("unexpected eof"));
assert!(is_connection_closed("connection closed"));
assert!(is_connection_closed(
"io error: broken pipe writing to stdin"
));
assert!(!is_connection_closed("unknown protocol method"));
assert!(!is_connection_closed(""));
}
}