pub mod config;
use std::sync::Arc;
use agent_client_protocol::schema::*;
use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Responder, Stdio};
use agent_client_protocol::{on_receive_notification, on_receive_request};
use crate::cli::Cli;
use crate::config::{Config, ProviderEntry};
use crate::context::ContextFiles;
use crate::event::AgentEvent;
use crate::permission::ask::AskSender;
use crate::permission::checker::{PermCheck, PermissionChecker};
use crate::permission::{PermissionConfig, SecurityMode};
use crate::provider::{ModelRoute, RouteRefusal, resolve_model_route};
use crate::sandbox::Sandbox;
use crate::session::{MessageRole, Session, ToolCallEntry, ToolCallState};
use compact_str::CompactString;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
struct AcpSession {
session: Session,
cwd: PathBuf,
model_override: Option<CompactString>,
provider_override: Option<CompactString>,
mode_override: Option<SecurityMode>,
run: Option<AcpRun>,
}
static NEXT_RUN_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
struct AcpRun {
generation: u64,
task: tokio::task::JoinHandle<()>,
cancel_tx: tokio::sync::mpsc::Sender<()>,
cancelled: Arc<AtomicBool>,
}
type SessionMap = tokio::sync::Mutex<HashMap<String, AcpSession>>;
async fn history_and_cwd(
sessions: &SessionMap,
id: &str,
) -> (Vec<rig::completion::Message>, Option<PathBuf>) {
let map = sessions.lock().await;
match map.get(id) {
Some(s) => (
crate::agent::runner::convert_history(&s.session),
Some(s.cwd.clone()),
),
None => (Vec::new(), None),
}
}
async fn register_run(
sessions: &SessionMap,
id: &str,
provider: &str,
model: &str,
mut run: AcpRun,
) -> u64 {
let generation = NEXT_RUN_GENERATION.fetch_add(1, Ordering::Relaxed);
run.generation = generation;
let mut map = sessions.lock().await;
let entry = map.entry(id.to_string()).or_insert_with(|| AcpSession {
session: Session::new(provider, model, 0),
cwd: std::env::current_dir().unwrap_or_default(),
model_override: None,
provider_override: None,
mode_override: None,
run: None,
});
if let Some(prev) = entry.run.replace(run) {
prev.task.abort();
}
generation
}
async fn finish_turn(
sessions: &SessionMap,
id: &str,
generation: u64,
prompt: &str,
response: &str,
tool_calls: Vec<ToolCallEntry>,
) {
let mut map = sessions.lock().await;
if let Some(s) = map.get_mut(id) {
s.session.add_message(MessageRole::User, prompt);
s.session
.add_message_with_tool_calls(MessageRole::Assistant, response, tool_calls);
if s.run.as_ref().is_some_and(|r| r.generation == generation) {
s.run = None;
}
}
}
async fn cancel_run(sessions: &SessionMap, id: &str) -> bool {
let mut map = sessions.lock().await;
let Some(s) = map.get_mut(id) else {
return false;
};
if let Some(run) = s.run.take() {
run.cancelled.store(true, Ordering::SeqCst);
let _ = run.cancel_tx.try_send(());
run.task.abort();
true
} else {
false
}
}
struct AcpState {
cli: Cli,
cfg: Config,
context: ContextFiles,
sessions: SessionMap,
}
pub async fn serve(cli: Cli, cfg: Config, context: ContextFiles) -> anyhow::Result<()> {
let state = Arc::new(AcpState {
cli,
cfg,
context,
sessions: tokio::sync::Mutex::new(HashMap::new()),
});
Agent
.builder()
.name("dirge")
.on_receive_request(
{
let state = state.clone();
move |req: InitializeRequest, responder, _cx| {
let state = state.clone();
async move { handle_initialize(req, responder, &state).await }
}
},
on_receive_request!(),
)
.on_receive_request(
{
let state = state.clone();
move |req: NewSessionRequest, responder, cx| {
let state = state.clone();
async move { handle_new_session(req, responder, cx, &state).await }
}
},
on_receive_request!(),
)
.on_receive_request(
{
let state = state.clone();
move |req: PromptRequest, responder, cx| {
let state = state.clone();
async move { handle_prompt(req, responder, cx, state).await }
}
},
on_receive_request!(),
)
.on_receive_notification(
{
let state = state.clone();
move |notif: CancelNotification, _cx| {
let state = state.clone();
async move {
let id = notif.session_id.to_string();
let cancelled = cancel_run(&state.sessions, &id).await;
tracing::info!("ACP session/cancel for {} (cancelled={})", id, cancelled);
Ok(())
}
}
},
on_receive_notification!(),
)
.on_receive_dispatch(
|dispatch: Dispatch<AgentRequest, AgentNotification>, cx: ConnectionTo<Client>| {
async move {
dispatch.respond_with_error(
agent_client_protocol::util::internal_error("Unhandled ACP message"),
cx,
)
}
},
agent_client_protocol::on_receive_dispatch!(),
)
.connect_to(Stdio::new())
.await
.map_err(|e| anyhow::anyhow!("ACP server error: {}", e))?;
Ok(())
}
async fn handle_initialize(
req: InitializeRequest,
responder: Responder<InitializeResponse>,
state: &AcpState,
) -> Result<(), agent_client_protocol::Error> {
let _ = state;
let caps = AgentCapabilities::new();
let resp = InitializeResponse::new(req.protocol_version)
.agent_capabilities(caps)
.agent_info(Implementation::new("dirge", "1.0.4"));
responder.respond(resp)
}
async fn handle_new_session(
req: NewSessionRequest,
responder: Responder<NewSessionResponse>,
cx: ConnectionTo<Client>,
state: &AcpState,
) -> Result<(), agent_client_protocol::Error> {
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
tracing::info!(
"ACP new session: {} (cwd: {})",
session_id,
req.cwd.display()
);
let provider = state.cli.resolve_provider(&state.cfg);
let model = state.cli.resolve_model(&state.cfg).to_string();
state.sessions.lock().await.insert(
session_id.to_string(),
AcpSession {
session: Session::new(&provider, &model, 0),
cwd: req.cwd.clone(),
model_override: None,
provider_override: None,
mode_override: None,
run: None,
},
);
let resp = NewSessionResponse::new(session_id.clone());
responder.respond(resp)?;
let notif = SessionNotification::new(
session_id,
SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(
acp_available_commands(),
)),
);
let _ = cx.send_notification(notif);
Ok(())
}
async fn handle_prompt(
req: PromptRequest,
responder: Responder<PromptResponse>,
cx: ConnectionTo<Client>,
state: Arc<AcpState>,
) -> Result<(), agent_client_protocol::Error> {
let session_id = req.session_id.clone();
tracing::info!("ACP prompt for session {}", session_id);
let prompt_text = req
.prompt
.iter()
.filter_map(|block| match block {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
cx.spawn({
let cx = cx.clone();
async move { run_prompt(&state, &prompt_text, session_id, responder, cx).await }
})
}
async fn run_prompt(
state: &AcpState,
prompt_text: &str,
session_id: SessionId,
responder: Responder<PromptResponse>,
cx: ConnectionTo<Client>,
) -> Result<(), agent_client_protocol::Error> {
let id_key = session_id.to_string();
let (model_override, provider_override, mode_override) =
session_overrides(&state.sessions, &id_key).await;
let provider_str = provider_override
.clone()
.unwrap_or_else(|| state.cli.resolve_provider(&state.cfg));
let (model_str, model_explicit) = if let Some(m) = model_override.clone() {
(m, true)
} else {
let config_model = state
.cfg
.resolve_role(crate::config::ConfigRole::Default)
.and_then(|(_, e)| e.model);
let model_explicit = state.cli.model.is_some() || config_model.is_some();
let model_str = if !model_explicit {
CompactString::new(crate::provider::default_model_for_alias(
&provider_str,
&state.cfg.providers_map(),
))
} else {
state.cli.resolve_model(&state.cfg)
};
(model_str, model_explicit)
};
let current_mode = resolve_acp_mode(&state.cli, &state.cfg, mode_override);
if let Some((cmd, args)) = parse_acp_slash(prompt_text)
&& let Some(reply) = handle_acp_slash(
&state.sessions,
&state.cfg,
&id_key,
cmd,
args,
AcpCurrent {
provider: &provider_str,
model: &model_str,
mode: current_mode,
},
)
.await
{
let _ = cx.send_notification(agent_text_chunk(&session_id, reply));
let _ = responder.respond(PromptResponse::new(StopReason::EndTurn));
return Ok(());
}
let client = create_acp_client(&provider_str, &state.cfg)
.map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
let model_str = crate::provider::resolve_model_name(&client, &model_str, model_explicit);
let model = client.completion_model(model_str.clone());
let (permission, ask_tx) = build_acp_permission(state, current_mode);
crate::permission::apply_prompt_deny(&permission, &state.context.current_prompt_deny_tools);
let sandbox = Sandbox::new(state.cli.resolve_sandbox(&state.cfg));
let bg_store = crate::agent::tools::background::BackgroundStore::new();
let agent = crate::provider::build_agent(
model,
&state.cli,
&state.cfg,
&state.context,
permission,
ask_tx,
None,
None,
Some(bg_store),
#[cfg(feature = "lsp")]
None,
sandbox,
#[cfg(feature = "mcp")]
None::<&crate::extras::mcp::McpClientManager>,
#[cfg(feature = "semantic")]
None::<&crate::semantic::SemanticManager>,
Some(session_id.to_string()),
)
.await;
let (history, cwd) = history_and_cwd(&state.sessions, &id_key).await;
if let Some(cwd) = cwd
&& cwd.is_dir()
{
let _ = std::env::set_current_dir(&cwd);
}
let runner = agent.spawn_runner(
crate::provider::Prompt::text(prompt_text.to_string()),
history,
None,
None,
);
let mut rx = runner.event_rx;
let cancelled = Arc::new(AtomicBool::new(false));
let generation = register_run(
&state.sessions,
&id_key,
&provider_str,
&model_str,
AcpRun {
generation: 0, task: runner.task,
cancel_tx: runner.cancel_tx,
cancelled: cancelled.clone(),
},
)
.await;
let mut full_response = String::new();
let mut turn_tool_calls: Vec<ToolCallEntry> = Vec::new();
let mut correlator = ToolCallCorrelator::default();
while let Some(event) = rx.recv().await {
match event {
AgentEvent::Token(text) => {
full_response.push_str(&text);
let chunk =
ContentChunk::new(ContentBlock::Text(TextContent::new(text.to_string())));
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(chunk),
);
let _ = cx.send_notification(notif);
}
AgentEvent::Reasoning(text) => {
let chunk =
ContentChunk::new(ContentBlock::Text(TextContent::new(text.to_string())));
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentThoughtChunk(chunk),
);
let _ = cx.send_notification(notif);
}
AgentEvent::ToolCall { id, name, args } => {
turn_tool_calls.push(ToolCallEntry {
id: id.to_string(),
name: name.to_string(),
args: args.clone(),
state: ToolCallState::Interrupted,
});
let args_str = args.to_string();
let acp_id = ToolCallId::new(uuid::Uuid::new_v4().to_string());
correlator.record(id.as_str(), acp_id.clone());
let tool_call = ToolCall::new(acp_id, name.to_string())
.raw_input(serde_json::from_str(&args_str).ok());
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(tool_call),
);
let _ = cx.send_notification(notif);
}
AgentEvent::ToolStarted { id } => {
if let Some(acp_id) = correlator.resolve(id.as_str()) {
let fields = ToolCallUpdateFields::new().status(ToolCallStatus::InProgress);
let update = ToolCallUpdate::new(acp_id, fields);
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCallUpdate(update),
);
let _ = cx.send_notification(notif);
}
}
AgentEvent::ToolResult { id, output, kind } => {
let rig_id = id.as_str();
let target = if !rig_id.is_empty() {
turn_tool_calls.iter_mut().rev().find(|e| e.id == rig_id)
} else {
turn_tool_calls
.iter_mut()
.rev()
.find(|e| matches!(e.state, ToolCallState::Interrupted))
};
if let Some(entry) = target {
entry.state = ToolCallState::Completed {
result: output.to_string(),
};
}
let id = correlator
.resolve(id.as_str())
.unwrap_or_else(|| ToolCallId::new(String::new()));
let _ = kind;
let fields = ToolCallUpdateFields::new()
.status(ToolCallStatus::Completed)
.content(vec![ToolCallContent::from(ContentBlock::Text(
TextContent::new(output.to_string()),
))]);
let update = ToolCallUpdate::new(id, fields);
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCallUpdate(update),
);
let _ = cx.send_notification(notif);
}
AgentEvent::Done { response, .. } => {
full_response = response.to_string();
break;
}
AgentEvent::Error(error) => {
let _ =
cx.send_notification(diagnostic_chunk(&session_id, format!("error: {error}")));
break;
}
AgentEvent::ContextOverflow { error, .. } => {
let _ = cx.send_notification(diagnostic_chunk(
&session_id,
format!("context overflow: {error}"),
));
break;
}
AgentEvent::CustomMessage { .. } => {
}
AgentEvent::TurnStart { .. }
| AgentEvent::TurnEnd { .. }
| AgentEvent::Usage { .. }
| AgentEvent::CompactionStarted { .. }
| AgentEvent::ContextCompacted { .. }
| AgentEvent::CheckpointRefresh { .. }
| AgentEvent::RetryNotice { .. }
| AgentEvent::SystemNotice { .. }
| AgentEvent::RepairStats { .. }
| AgentEvent::EscalationActivated { .. } => {
}
AgentEvent::UserMessage { .. } => {
}
AgentEvent::Interjected { .. } => {
break;
} }
}
finish_turn(
&state.sessions,
&id_key,
generation,
prompt_text,
&full_response,
turn_tool_calls,
)
.await;
let reason = if cancelled.load(Ordering::SeqCst) {
StopReason::Cancelled
} else {
StopReason::EndTurn
};
let _ = responder.respond(PromptResponse::new(reason));
Ok(())
}
fn agent_text_chunk(session_id: &SessionId, text: String) -> SessionNotification {
SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(
text,
)))),
)
}
fn diagnostic_chunk(session_id: &SessionId, text: String) -> SessionNotification {
agent_text_chunk(session_id, text)
}
const ACP_COMMANDS: &[(&str, &str, Option<&str>)] = &[
("help", "list dirge's ACP slash commands", None),
("clear", "clear this session's conversation history", None),
(
"cd",
"change the session working directory",
Some("directory path"),
),
(
"model",
"show the current model, or switch to one",
Some("model id (optional)"),
),
(
"mode",
"view or set the permission mode",
Some("standard | restrictive | accept | yolo"),
),
];
fn acp_available_commands() -> Vec<AvailableCommand> {
ACP_COMMANDS
.iter()
.map(|(name, desc, hint)| {
let cmd = AvailableCommand::new(*name, *desc);
match hint {
Some(h) => cmd.input(AvailableCommandInput::Unstructured(
UnstructuredCommandInput::new(*h),
)),
None => cmd,
}
})
.collect()
}
fn acp_help_text() -> String {
let mut out = String::from("dirge slash commands:\n");
for (name, desc, _) in ACP_COMMANDS {
out.push_str(&format!(" /{name} — {desc}\n"));
}
out.push_str("\nother `/...` input is sent to the model as-is.");
out
}
fn parse_acp_slash(prompt: &str) -> Option<(&str, &str)> {
let rest = prompt.trim().strip_prefix('/')?;
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
return None;
}
Some(match rest.split_once(char::is_whitespace) {
Some((cmd, args)) => (cmd, args.trim()),
None => (rest, ""),
})
}
struct AcpCurrent<'a> {
provider: &'a str,
model: &'a str,
mode: SecurityMode,
}
async fn handle_acp_slash(
sessions: &SessionMap,
cfg: &Config,
id: &str,
cmd: &str,
args: &str,
current: AcpCurrent<'_>,
) -> Option<String> {
match cmd {
"help" => Some(acp_help_text()),
"clear" => {
let mut map = sessions.lock().await;
if let Some(s) = map.get_mut(id) {
s.session.messages.clear();
s.session.total_estimated_tokens = 0;
s.session.compactions.clear();
s.session.message_store.clear();
s.session.tree.entries.clear();
s.session.tree.leaf_id = None;
}
Some("conversation history cleared".to_string())
}
"cd" => Some(acp_cd(sessions, id, args).await),
"model" => Some(acp_model(sessions, cfg, id, args, current.provider, current.model).await),
"mode" => Some(acp_mode(sessions, id, args, current.mode).await),
_ => None,
}
}
async fn acp_cd(sessions: &SessionMap, id: &str, args: &str) -> String {
let mut map = sessions.lock().await;
let Some(s) = map.get_mut(id) else {
return "cd: unknown session".to_string();
};
let target = args.trim();
let path = if target.is_empty() {
dirs::home_dir().unwrap_or_default()
} else if let Some(rest) = target.strip_prefix('~') {
let mut home = dirs::home_dir().unwrap_or_default();
home.push(rest.trim_start_matches('/'));
home
} else {
let p = PathBuf::from(target);
if p.is_absolute() { p } else { s.cwd.join(p) }
};
match dunce::canonicalize(&path) {
Ok(canonical) if canonical.is_dir() => {
s.cwd = canonical.clone();
format!("changed directory to {}", canonical.display())
}
Ok(_) => format!("cd: not a directory: {}", path.display()),
Err(e) => format!("cd: {}: {e}", path.display()),
}
}
fn acp_configured_models(
providers: &HashMap<String, ProviderEntry>,
current: &str,
) -> Vec<(String, String, bool)> {
let mut rows: Vec<(String, String, bool)> = providers
.iter()
.filter_map(|(alias, entry)| {
entry
.model
.as_ref()
.map(|m| (m.clone(), alias.clone(), m == current))
})
.collect();
rows.sort();
rows
}
async fn acp_model(
sessions: &SessionMap,
cfg: &Config,
id: &str,
args: &str,
current_provider: &str,
current_model: &str,
) -> String {
let providers = cfg.providers_map();
let new_model = args.trim();
if new_model.is_empty() {
let mut out = format!("current model: {current_model}\n");
let rows = acp_configured_models(&providers, current_model);
if rows.is_empty() {
out.push_str(
"no models pinned in `providers` config — /model <id> switches to any model your provider supports",
);
} else {
out.push_str("configured models:\n");
for (model, alias, is_active) in &rows {
let marker = if *is_active { "* " } else { " " };
out.push_str(&format!("{marker}{model} · {alias}\n"));
}
out.push_str("usage: /model <id> to switch");
}
return out;
}
let (provider_override, note) = match resolve_model_route(cfg, current_provider, new_model) {
ModelRoute::Provider { alias, .. } => {
let note = format!(" · {alias}");
(Some(alias), note)
}
ModelRoute::Unroutable { model, family } => {
let refusal = RouteRefusal::NoProviderForFamily { model, family };
return format!("{refusal} Keeping model '{current_model}' on '{current_provider}'.",);
}
ModelRoute::Active(_) => (None, String::new()),
};
let mut map = sessions.lock().await;
if let Some(s) = map.get_mut(id) {
s.model_override = Some(CompactString::new(new_model));
s.session.model = CompactString::new(new_model);
if let Some(alias) = &provider_override {
s.provider_override = Some(CompactString::new(alias));
s.session.provider = CompactString::new(alias);
}
}
format!("switched to model: {new_model}{note}")
}
async fn acp_mode(
sessions: &SessionMap,
id: &str,
args: &str,
current_mode: SecurityMode,
) -> String {
let arg = args.trim();
if arg.is_empty() {
return format!(
"security mode: {current_mode}\n\n /mode standard use configured permission rules\n /mode restrictive default all tools to ask\n /mode accept auto-accept within working directory\n /mode yolo auto-accept ALL operations",
);
}
let new_mode = match arg {
"standard" => SecurityMode::Standard,
"restrictive" => SecurityMode::Restrictive,
"accept" => SecurityMode::Accept,
"yolo" => SecurityMode::Yolo,
other => return format!("unknown mode: {other}"),
};
if let Some(s) = sessions.lock().await.get_mut(id) {
s.mode_override = Some(new_mode);
}
match new_mode {
SecurityMode::Yolo => "security mode: yolo (all operations allowed)".to_string(),
SecurityMode::Accept => "security mode: accept (auto-allow within CWD)".to_string(),
other => format!("security mode: {other}"),
}
}
async fn session_overrides(
sessions: &SessionMap,
id: &str,
) -> (
Option<CompactString>,
Option<CompactString>,
Option<SecurityMode>,
) {
let map = sessions.lock().await;
match map.get(id) {
Some(s) => (
s.model_override.clone(),
s.provider_override.clone(),
s.mode_override,
),
None => (None, None, None),
}
}
fn create_acp_client(
provider_str: &str,
cfg: &Config,
) -> anyhow::Result<crate::provider::AnyClient> {
crate::provider::create_client_with_auth(provider_str, None, &cfg.providers_map(), cfg.auth)
}
fn build_acp_permission(
state: &AcpState,
mode: SecurityMode,
) -> (Option<PermCheck>, Option<AskSender>) {
use std::sync::Mutex;
let no_tools = state.cli.resolve_no_tools(&state.cfg);
if no_tools {
return (None, None);
}
let perm_config: PermissionConfig = state
.cfg
.permission
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let checker = PermissionChecker::new(&perm_config, mode, None);
let perm: PermCheck = Arc::new(Mutex::new(checker));
let (ask_tx, ask_rx) = tokio::sync::mpsc::channel(64);
spawn_acp_ask_drain(ask_rx);
(Some(perm), Some(ask_tx))
}
#[derive(Default)]
struct ToolCallCorrelator {
by_id: std::collections::HashMap<String, ToolCallId>,
fifo: std::collections::VecDeque<ToolCallId>,
}
impl ToolCallCorrelator {
fn record(&mut self, rig_id: &str, acp_id: ToolCallId) {
if rig_id.is_empty() {
self.fifo.push_back(acp_id);
} else {
self.by_id.insert(rig_id.to_string(), acp_id);
}
}
fn resolve(&mut self, rig_id: &str) -> Option<ToolCallId> {
if !rig_id.is_empty() {
self.by_id.remove(rig_id)
} else {
self.fifo.pop_front()
}
}
}
fn spawn_acp_ask_drain(
mut ask_rx: tokio::sync::mpsc::Receiver<crate::permission::ask::AskRequest>,
) {
tokio::spawn(async move {
while let Some(req) = ask_rx.recv().await {
let _ = req.reply.send(crate::permission::ask::UserDecision::Deny);
}
});
}
fn resolve_acp_mode(cli: &Cli, cfg: &Config, override_mode: Option<SecurityMode>) -> SecurityMode {
if let Some(mode) = override_mode {
return mode;
}
if cli.yolo || cfg.yolo.unwrap_or(false) {
SecurityMode::Yolo
} else if cli.accept_all || cfg.accept_all.unwrap_or(false) {
SecurityMode::Accept
} else if cli.restrictive || cfg.restrictive.unwrap_or(false) {
SecurityMode::Restrictive
} else if let Some(m) = &cfg.default_permission_mode {
match m.as_str() {
"yolo" => SecurityMode::Yolo,
"accept" => SecurityMode::Accept,
"restrictive" => SecurityMode::Restrictive,
_ => SecurityMode::Standard,
}
} else {
SecurityMode::Standard
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProviderAuth;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static ACP_AUTH_ENV_LOCK: Mutex<()> = Mutex::new(());
struct TestDir(PathBuf);
impl TestDir {
fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"dirge_acp_{tag}_{}_{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set_path(key: &'static str, value: &Path) -> Self {
let old = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, old }
}
fn remove(key: &'static str) -> Self {
let old = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.old {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
fn acp_client_uses_top_level_chatgpt_auth() {
let _lock = ACP_AUTH_ENV_LOCK.lock().unwrap();
let dir = TestDir::new("codex_home");
let _home = EnvGuard::set_path("CODEX_HOME", dir.path());
let _access = EnvGuard::remove("CODEX_ACCESS_TOKEN");
let _account = EnvGuard::remove("CHATGPT_ACCOUNT_ID");
let cfg = Config {
auth: Some(ProviderAuth::ChatGpt),
..Default::default()
};
let result = create_acp_client("openai", &cfg);
let err = match result {
Ok(_) => panic!("ACP client should attempt ChatGPT auth"),
Err(err) => err.to_string(),
};
assert!(
err.contains("ChatGPT auth requested"),
"unexpected error: {err}"
);
assert!(!err.contains("OPENAI_API_KEY"), "unexpected error: {err}");
}
#[tokio::test]
async fn acp_ask_drain_responds_with_deny() {
let (ask_tx, ask_rx) = tokio::sync::mpsc::channel::<crate::permission::ask::AskRequest>(8);
spawn_acp_ask_drain(ask_rx);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
ask_tx
.send(crate::permission::ask::AskRequest {
tool: "bash".to_string(),
input: "rm -rf /".to_string(),
reason: None,
reply: reply_tx,
})
.await
.expect("send must succeed");
let resp = tokio::time::timeout(std::time::Duration::from_millis(200), reply_rx)
.await
.expect("must reply within 200ms — F1 regression")
.expect("reply channel must not be dropped");
assert!(
matches!(resp, crate::permission::ask::UserDecision::Deny),
"ACP ask must auto-deny; got {:?}",
resp,
);
}
#[test]
fn correlator_matches_parallel_tool_calls_by_id() {
let mut c = ToolCallCorrelator::default();
let acp_a = ToolCallId::new("acp-A".to_string());
let acp_b = ToolCallId::new("acp-B".to_string());
c.record("rig-A", acp_a.clone());
c.record("rig-B", acp_b.clone());
assert_eq!(c.resolve("rig-B"), Some(acp_b));
assert_eq!(c.resolve("rig-A"), Some(acp_a));
}
#[test]
fn correlator_uses_fifo_for_empty_rig_ids() {
let mut c = ToolCallCorrelator::default();
let acp_a = ToolCallId::new("acp-A".to_string());
let acp_b = ToolCallId::new("acp-B".to_string());
c.record("", acp_a.clone());
c.record("", acp_b.clone());
assert_eq!(c.resolve(""), Some(acp_a));
assert_eq!(c.resolve(""), Some(acp_b));
}
#[test]
fn correlator_separates_id_and_fifo_buckets() {
let mut c = ToolCallCorrelator::default();
let acp_named = ToolCallId::new("acp-named".to_string());
let acp_anon = ToolCallId::new("acp-anon".to_string());
c.record("rig-X", acp_named.clone());
c.record("", acp_anon.clone());
assert_eq!(c.resolve(""), Some(acp_anon));
assert_eq!(c.resolve("rig-X"), Some(acp_named));
}
#[test]
fn correlator_returns_none_for_unknown_id() {
let mut c = ToolCallCorrelator::default();
assert_eq!(c.resolve("missing"), None);
assert_eq!(c.resolve(""), None);
}
#[test]
fn diagnostic_chunk_carries_the_error_text() {
let sid = SessionId::new("sess-err".to_string());
let notif = diagnostic_chunk(&sid, "error: provider auth failed".to_string());
match notif.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
ContentBlock::Text(t) => {
assert!(
t.text.contains("provider auth failed"),
"chunk must carry the diagnostic text, got {:?}",
t.text
);
}
other => panic!("expected a text content block, got {other:?}"),
},
other => panic!("expected an AgentMessageChunk, got {other:?}"),
}
}
fn session_map_with(id: &str) -> SessionMap {
let mut map = std::collections::HashMap::new();
map.insert(
id.to_string(),
AcpSession {
session: crate::session::Session::new("p", "m", 0),
cwd: std::env::temp_dir(),
model_override: None,
provider_override: None,
mode_override: None,
run: None,
},
);
tokio::sync::Mutex::new(map)
}
fn user_texts(history: &[rig::completion::Message]) -> Vec<String> {
history
.iter()
.filter_map(|m| match m {
rig::completion::Message::User { content } => Some(
content
.iter()
.filter_map(|c| match c {
rig::message::UserContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join(""),
),
_ => None,
})
.collect()
}
#[tokio::test]
async fn finish_turn_accumulates_multi_turn_history() {
let id = "sess-1";
let sessions = session_map_with(id);
let (h0, _) = history_and_cwd(&sessions, id).await;
assert!(h0.is_empty(), "fresh session starts with no history");
finish_turn(
&sessions,
id,
0,
"what does foo do?",
"foo does X.",
Vec::new(),
)
.await;
let (h1, _) = history_and_cwd(&sessions, id).await;
let texts1 = user_texts(&h1);
assert!(
texts1.iter().any(|t| t.contains("what does foo do?")),
"second prompt must see the first user turn, got {texts1:?}"
);
finish_turn(&sessions, id, 0, "now change it", "changed.", Vec::new()).await;
let (h2, _) = history_and_cwd(&sessions, id).await;
let texts2 = user_texts(&h2);
assert!(
texts2.iter().any(|t| t.contains("what does foo do?"))
&& texts2.iter().any(|t| t.contains("now change it")),
"history must retain both prior turns, got {texts2:?}"
);
}
#[tokio::test]
async fn history_for_unknown_session_is_empty() {
let sessions: SessionMap = tokio::sync::Mutex::new(std::collections::HashMap::new());
let (h, cwd) = history_and_cwd(&sessions, "nope").await;
assert!(h.is_empty());
assert!(cwd.is_none());
}
#[tokio::test]
async fn cancel_run_aborts_and_flags() {
let id = "sess-c";
let sessions = session_map_with(id);
let task = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
});
let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(4);
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
register_run(
&sessions,
id,
"p",
"m",
AcpRun {
generation: 0,
task,
cancel_tx,
cancelled: cancelled.clone(),
},
)
.await;
assert!(
cancel_run(&sessions, id).await,
"an active run is cancelled"
);
assert!(
cancelled.load(std::sync::atomic::Ordering::SeqCst),
"the cancel flag is set so the loop reports Cancelled"
);
assert!(
cancel_rx.try_recv().is_ok(),
"the cooperative cancel signal was sent"
);
assert!(!cancel_run(&sessions, id).await, "no run left to cancel");
}
fn stub_run() -> (AcpRun, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let task = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
});
let (cancel_tx, _cancel_rx) = tokio::sync::mpsc::channel::<()>(4);
std::mem::forget(_cancel_rx);
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
(
AcpRun {
generation: 0,
task,
cancel_tx,
cancelled: cancelled.clone(),
},
cancelled,
)
}
#[tokio::test]
async fn finish_turn_stale_generation_keeps_replacement_run() {
let id = "sess-overlap";
let sessions = session_map_with(id);
let (run_a, _) = stub_run();
let gen_a = register_run(&sessions, id, "p", "m", run_a).await;
let (run_b, cancelled_b) = stub_run();
let gen_b = register_run(&sessions, id, "p", "m", run_b).await;
assert_ne!(gen_a, gen_b, "each registration gets a fresh generation");
finish_turn(&sessions, id, gen_a, "prompt A", "partial", Vec::new()).await;
let (h, _) = history_and_cwd(&sessions, id).await;
assert!(
user_texts(&h).iter().any(|t| t.contains("prompt A")),
"the aborted turn still lands in history"
);
assert!(
cancel_run(&sessions, id).await,
"B must remain cancellable after A's stale finish_turn"
);
assert!(
cancelled_b.load(std::sync::atomic::Ordering::SeqCst),
"the cancel hit B's flag"
);
}
#[tokio::test]
async fn finish_turn_matching_generation_clears_run() {
let id = "sess-own";
let sessions = session_map_with(id);
let (run, _) = stub_run();
let generation = register_run(&sessions, id, "p", "m", run).await;
finish_turn(&sessions, id, generation, "prompt", "reply", Vec::new()).await;
assert!(
!cancel_run(&sessions, id).await,
"the finished run's slot is cleared"
);
}
#[tokio::test]
async fn cancel_run_unknown_session_is_false() {
let sessions: SessionMap = tokio::sync::Mutex::new(std::collections::HashMap::new());
assert!(!cancel_run(&sessions, "ghost").await);
}
#[test]
fn available_commands_are_the_curated_set() {
let cmds = acp_available_commands();
let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["help", "clear", "cd", "model", "mode"]);
assert!(
names.iter().all(|n| !n.starts_with('/')),
"ACP command names must not carry the leading slash",
);
for c in &cmds {
assert!(!c.description.is_empty(), "{} needs a description", c.name);
let wants_input = matches!(c.name.as_str(), "cd" | "model" | "mode");
assert_eq!(
c.input.is_some(),
wants_input,
"input hint presence wrong for {}",
c.name,
);
}
}
#[test]
fn help_text_lists_every_command() {
let text = acp_help_text();
for (name, _, _) in ACP_COMMANDS {
assert!(text.contains(&format!("/{name}")), "help missing /{name}");
}
}
#[test]
fn parse_slash_splits_command_and_args() {
assert_eq!(parse_acp_slash("/help"), Some(("help", "")));
assert_eq!(
parse_acp_slash(" /cd /tmp/foo "),
Some(("cd", "/tmp/foo"))
);
assert_eq!(
parse_acp_slash("/model gpt-4o mini"),
Some(("model", "gpt-4o mini")),
);
assert_eq!(parse_acp_slash("hello /help"), None);
assert_eq!(parse_acp_slash("/"), None);
assert_eq!(parse_acp_slash("/ spaced"), None);
assert_eq!(parse_acp_slash(""), None);
}
#[tokio::test]
async fn unknown_slash_command_is_not_handled() {
let sessions = session_map_with("s");
let cfg = Config::default();
let out = handle_acp_slash(
&sessions,
&cfg,
"s",
"panel",
"",
AcpCurrent {
provider: "openrouter",
model: "some-model",
mode: SecurityMode::Standard,
},
)
.await;
assert!(
out.is_none(),
"TUI-only /panel must fall through to the LLM"
);
}
#[tokio::test]
async fn clear_empties_session_history() {
let id = "s";
let sessions = session_map_with(id);
finish_turn(&sessions, id, 0, "hi", "hello", Vec::new()).await;
let (before, _) = history_and_cwd(&sessions, id).await;
assert!(!before.is_empty(), "precondition: a turn is recorded");
let reply = handle_acp_slash(
&sessions,
&Config::default(),
id,
"clear",
"",
AcpCurrent {
provider: "p",
model: "m",
mode: SecurityMode::Standard,
},
)
.await;
assert!(reply.unwrap().contains("cleared"));
let (after, _) = history_and_cwd(&sessions, id).await;
assert!(after.is_empty(), "history must be empty after /clear");
}
#[tokio::test]
async fn cd_updates_cwd_and_rejects_bad_paths() {
let id = "s";
let sessions = session_map_with(id);
let dir = TestDir::new("cd");
let canonical = dunce::canonicalize(dir.path()).unwrap();
let reply = acp_cd(&sessions, id, &dir.path().to_string_lossy()).await;
assert!(reply.contains("changed directory"), "got {reply}");
assert_eq!(
sessions.lock().await.get(id).unwrap().cwd,
canonical,
"cwd updated to the canonical target",
);
let bad = acp_cd(&sessions, id, "/no/such/dir/exists/here").await;
assert!(bad.starts_with("cd:"), "bad path reports an error: {bad}");
assert_eq!(
sessions.lock().await.get(id).unwrap().cwd,
canonical,
"a failed /cd leaves the cwd unchanged",
);
}
#[tokio::test]
async fn model_lists_then_switches() {
let id = "s";
let sessions = session_map_with(id);
let cfg = Config::default();
let list = acp_model(&sessions, &cfg, id, "", "openrouter", "current-x").await;
assert!(list.contains("current model: current-x"), "got {list}");
let set = acp_model(&sessions, &cfg, id, "llama-3.1", "openrouter", "current-x").await;
assert!(set.contains("switched to model: llama-3.1"), "got {set}");
let (model_ovr, provider_ovr, _) = session_overrides(&sessions, id).await;
assert_eq!(model_ovr.as_deref(), Some("llama-3.1"));
assert!(
provider_ovr.is_none(),
"a same-provider swap keeps the provider",
);
}
#[tokio::test]
async fn mode_views_sets_and_rejects() {
let id = "s";
let sessions = session_map_with(id);
let view = acp_mode(&sessions, id, "", SecurityMode::Standard).await;
assert!(view.contains("security mode: standard"), "got {view}");
let set = acp_mode(&sessions, id, "yolo", SecurityMode::Standard).await;
assert!(set.contains("yolo"), "got {set}");
let (_, _, mode_ovr) = session_overrides(&sessions, id).await;
assert_eq!(mode_ovr, Some(SecurityMode::Yolo));
let bad = acp_mode(&sessions, id, "bogus", SecurityMode::Standard).await;
assert!(bad.contains("unknown mode"), "got {bad}");
let (_, _, still_yolo) = session_overrides(&sessions, id).await;
assert_eq!(still_yolo, Some(SecurityMode::Yolo), "bad arg is a no-op");
}
#[test]
fn resolve_mode_prefers_session_override() {
use clap::Parser as _;
let cli = Cli::parse_from(["dirge"]);
let cfg = Config::default();
assert_eq!(
resolve_acp_mode(&cli, &cfg, Some(SecurityMode::Accept)),
SecurityMode::Accept,
);
assert_eq!(resolve_acp_mode(&cli, &cfg, None), SecurityMode::Standard,);
}
#[tokio::test]
async fn acp_ask_drain_handles_multiple_concurrent_asks() {
let (ask_tx, ask_rx) = tokio::sync::mpsc::channel::<crate::permission::ask::AskRequest>(8);
spawn_acp_ask_drain(ask_rx);
let mut replies = Vec::new();
for i in 0..5 {
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
ask_tx
.send(crate::permission::ask::AskRequest {
tool: format!("bash-{i}"),
input: format!("cmd-{i}"),
reason: None,
reply: reply_tx,
})
.await
.unwrap();
replies.push(reply_rx);
}
for reply_rx in replies {
let resp = tokio::time::timeout(std::time::Duration::from_millis(500), reply_rx)
.await
.expect("each reply must arrive promptly")
.expect("reply channel dropped");
assert!(matches!(resp, crate::permission::ask::UserDecision::Deny));
}
}
}