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;
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::sandbox::Sandbox;
use crate::session::{MessageRole, Session, ToolCallEntry, ToolCallState};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
struct AcpSession {
session: Session,
cwd: PathBuf,
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(),
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(),
run: None,
},
);
let resp = NewSessionResponse::new(session_id);
responder.respond(resp)
}
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 provider_str = state.cli.resolve_provider(&state.cfg);
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 {
compact_str::CompactString::new(crate::provider::default_model_for_alias(
&provider_str,
&state.cfg.providers_map(),
))
} else {
state.cli.resolve_model(&state.cfg)
};
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);
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 id_key = session_id.to_string();
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(prompt_text.to_string(), history, 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(_) => {
break;
}
AgentEvent::ContextOverflow { error, .. } => {
let chunk = ContentChunk::new(ContentBlock::Text(TextContent::new(format!(
"context overflow: {}",
error
))));
let notif = SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(chunk),
);
let _ = cx.send_notification(notif);
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 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) -> (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 mode = resolve_acp_mode(&state.cli, &state.cfg);
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) -> SecurityMode {
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);
}
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(),
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);
}
#[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));
}
}
}