1#[cfg(windows)]
26use agentmux_common::win32::NoWindow;
27use std::collections::{HashMap, HashSet};
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::{Arc, Mutex};
30
31use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
32use tokio::sync::mpsc;
33
34use super::{
35 agent_runtime_status, BlockControllerRuntimeStatus, BlockInputUnion, Controller, STATUS_DONE,
36 STATUS_INIT, STATUS_RUNNING,
37};
38use super::core;
39use super::meta_string_map;
40use super::health::TurnActivityTracker;
41use crate::backend::eventbus::EventBus;
42use crate::backend::storage::filestore::FileStore;
43use crate::backend::storage::store::Store;
44use crate::backend::mps;
45
46pub const ACP_OUTPUT_SUBJECT: &str = "output";
48
49pub const BLOCK_CONTROLLER_ACP: &str = "acp";
50
51const TURN_ENDED_BY_ERROR: &str = r#"{"jsonrpc":"2.0","result":{"stopReason":"error"}}"#;
56
57fn initialize_params() -> serde_json::Value {
63 serde_json::json!({
64 "protocolVersion": 1,
65 "clientCapabilities": {
66 "fs": { "readTextFile": false, "writeTextFile": false },
67 "terminal": false,
68 },
69 "clientInfo": { "name": "AgentMux", "version": env!("CARGO_PKG_VERSION") },
70 })
71}
72
73fn session_request(resume: Option<&str>, load_session: bool, cwd: &str) -> (&'static str, serde_json::Value) {
75 match resume.filter(|_| load_session) {
76 Some(sid) => (
77 "session/load",
78 serde_json::json!({ "sessionId": sid, "cwd": cwd, "mcpServers": [] }),
79 ),
80 None => ("session/new", serde_json::json!({ "cwd": cwd, "mcpServers": [] })),
81 }
82}
83
84fn is_session_replay(loading: bool, is_session_response: bool) -> bool {
88 loading && !is_session_response
89}
90
91fn session_cwd(working_dir: &str) -> String {
96 let here = std::env::current_dir().unwrap_or_default();
97 let dir = if working_dir.is_empty() {
98 here.clone()
99 } else {
100 let expanded = std::path::PathBuf::from(super::core::expand_home_dir(working_dir));
101 if expanded.is_absolute() { expanded } else { here.join(expanded) }
102 };
103 dir.to_string_lossy().into_owned()
104}
105
106fn prompt_params(session_id: &str, text: &str) -> serde_json::Value {
108 serde_json::json!({
109 "sessionId": session_id,
110 "prompt": [{ "type": "text", "text": text }],
111 })
112}
113
114fn reply_to_agent_request(json: &serde_json::Value) -> Option<serde_json::Value> {
120 let method = json.get("method")?.as_str()?;
121 let id = json.get("id")?.clone();
124 if method == "session/request_permission" {
125 let options = json
126 .pointer("/params/options")
127 .and_then(|o| o.as_array())
128 .cloned()
129 .unwrap_or_default();
130 let pick = |kind: &str| {
131 options
132 .iter()
133 .find(|o| o.get("kind").and_then(|k| k.as_str()) == Some(kind))
134 .and_then(|o| o.get("optionId").cloned())
135 };
136 let outcome = match pick("allow_always").or_else(|| pick("allow_once")) {
137 Some(option_id) => serde_json::json!({ "outcome": "selected", "optionId": option_id }),
138 None => serde_json::json!({ "outcome": "cancelled" }),
139 };
140 return Some(serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { "outcome": outcome } }));
141 }
142 Some(serde_json::json!({
143 "jsonrpc": "2.0",
144 "id": id,
145 "error": { "code": -32601, "message": format!("AgentMux does not offer {method}") },
146 }))
147}
148
149fn resolve_prompt_and_check_idle(outstanding: &mut HashSet<u64>, resolved_id: u64) -> bool {
171 outstanding.remove(&resolved_id);
172 outstanding.is_empty()
173}
174
175struct AcpInner {
177 proc_status: String,
178 proc_exit_code: i32,
179 status_version: i32,
180 session_id: Option<String>,
181 current_pid: Option<u32>,
182 stdin_tx: Option<mpsc::Sender<String>>,
183 kill_tx: Option<tokio::sync::oneshot::Sender<bool>>,
184 pending_prompt: Option<String>,
186 init_request_id: Option<u64>,
189 session_request_id: Option<u64>,
191 loading_session: Option<String>,
193 session_failed: bool,
196 generation: u64,
200 resume_session_id: Option<String>,
203 session_cwd: String,
205}
206
207pub struct AcpController {
209 #[allow(dead_code)]
210 tab_id: String,
211 block_id: String,
212 inner: Arc<Mutex<AcpInner>>,
213 broker: Option<Arc<mps::Broker>>,
214 event_bus: Option<Arc<EventBus>>,
215 mstore: Option<Arc<Store>>,
216 filestore: Option<Arc<FileStore>>,
217 health_monitor: Arc<TurnActivityTracker>,
218 next_rpc_id: Arc<AtomicU64>,
220 outstanding_prompt_ids: Arc<Mutex<HashSet<u64>>>,
231 start_lock: Mutex<()>,
234}
235
236impl AcpController {
237 pub fn new(
238 tab_id: String,
239 block_id: String,
240 broker: Option<Arc<mps::Broker>>,
241 event_bus: Option<Arc<EventBus>>,
242 mstore: Option<Arc<Store>>,
243 filestore: Option<Arc<FileStore>>,
244 ) -> Self {
245 let health_monitor = Arc::new(TurnActivityTracker::for_block(block_id.clone(), broker.as_ref()));
246 Self {
247 tab_id,
248 block_id,
249 inner: Arc::new(Mutex::new(AcpInner {
250 proc_status: STATUS_INIT.to_string(),
251 proc_exit_code: 0,
252 status_version: 0,
253 session_id: None,
254 current_pid: None,
255 stdin_tx: None,
256 kill_tx: None,
257 pending_prompt: None,
258 init_request_id: None,
259 session_request_id: None,
260 loading_session: None,
261 session_failed: false,
262 generation: 0,
263 resume_session_id: None,
264 session_cwd: String::new(),
265 })),
266 broker,
267 event_bus,
268 mstore,
269 filestore,
270 health_monitor,
271 next_rpc_id: Arc::new(AtomicU64::new(1)),
272 outstanding_prompt_ids: Arc::new(Mutex::new(HashSet::new())),
273 start_lock: Mutex::new(()),
274 }
275 }
276
277 fn next_id(&self) -> u64 {
278 self.next_rpc_id.fetch_add(1, Ordering::Relaxed)
279 }
280
281 fn set_status(inner: &mut AcpInner, status: &str) {
282 inner.proc_status = status.to_string();
283 inner.status_version += 1;
284 }
285
286 fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
287 let inner = self.inner.lock().unwrap();
288 agent_runtime_status(
289 &self.block_id,
290 inner.status_version,
291 &inner.proc_status,
292 inner.proc_exit_code,
293 self.health_monitor.is_active_turn(),
294 )
295 }
296
297 fn publish_status(&self) {
298 if let Some(ref broker) = self.broker {
299 let status = self.get_status_snapshot();
300 super::publish_controller_status(broker, &status);
301 }
302 }
303
304 pub(crate) fn is_running(&self) -> bool {
305 let inner = self.inner.lock().unwrap();
306 inner.stdin_tx.is_some()
307 }
308
309 fn make_request(&self, method: &str, params: serde_json::Value) -> String {
311 let id = self.next_id();
312 serde_json::json!({
313 "jsonrpc": "2.0",
314 "id": id,
315 "method": method,
316 "params": params,
317 }).to_string()
318 }
319
320 fn make_notification(&self, method: &str, params: serde_json::Value) -> String {
322 serde_json::json!({
323 "jsonrpc": "2.0",
324 "method": method,
325 "params": params,
326 }).to_string()
327 }
328
329 fn spawn_process(&self, cli_command: String, cli_args: Vec<String>, working_dir: String, env_vars: HashMap<String, String>) -> Result<(), String> {
331 let mut cmd = crate::server::cli_handlers::make_cli_cmd(&cli_command);
332 cmd.args(&cli_args);
333
334 core::apply_working_dir(&mut cmd, &self.block_id, &working_dir, &env_vars);
335 crate::backend::identity_spawn::record_process_spawn(
337 &self.block_id,
338 crate::backend::identity_spawn::SpawnPath::Acp,
339 &env_vars,
340 );
341 #[cfg(windows)]
345 {
346 cmd.no_window();
347 }
348
349 cmd.stdin(std::process::Stdio::piped());
350 cmd.stdout(std::process::Stdio::piped());
351 cmd.stderr(std::process::Stdio::piped());
352
353 let mut child = crate::backend::process_tracker::registry::spawn_tracked(&self.block_id, &mut cmd, crate::backend::process_tracker::registry::Join::Agent).map_err(|e| {
357 tracing::error!(block_id = %self.block_id, error = %e, "ACP process spawn failed");
358 format!("failed to spawn ACP process: {e}")
359 })?;
360
361 let pid = child.id().unwrap_or(0);
362
363 tracing::info!(
364 block_id = %self.block_id,
365 pid = pid,
366 cmd = %cli_command,
367 args = ?cli_args,
368 "ACP agent process spawned"
369 );
370
371 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<bool>();
372 let stdin = child.stdin.take()
373 .ok_or_else(|| format!("[acp] stdin not captured for block {}", self.block_id))?;
374 let stdout = child.stdout.take()
375 .ok_or_else(|| format!("[acp] stdout not captured for block {}", self.block_id))?;
376 let stderr = child.stderr.take();
377
378 if let Some(stderr_pipe) = stderr {
380 let block_id_stderr = self.block_id.clone();
381 tokio::spawn(async move {
382 let mut reader = BufReader::new(stderr_pipe).lines();
383 loop {
384 match reader.next_line().await {
385 Err(e) => {
386 tracing::warn!(block_id = %block_id_stderr, error = %e, "ACP stderr read error");
387 break;
388 }
389 Ok(None) => break,
390 Ok(Some(line)) => {
391 if !line.trim().is_empty() {
392 tracing::warn!(
393 block_id = %block_id_stderr,
394 line = %line,
395 "ACP agent stderr"
396 );
397 }
398 }
399 }
400 }
401 });
402 }
403
404 let (msg_tx, mut msg_rx) = mpsc::channel::<String>(32);
406
407 {
408 let mut inner = self.inner.lock().unwrap();
409 inner.current_pid = Some(pid);
410 inner.kill_tx = Some(kill_tx);
411 inner.stdin_tx = Some(msg_tx.clone());
412 Self::set_status(&mut inner, STATUS_RUNNING);
413 }
414 self.publish_status();
415
416 let block_id_stdin = self.block_id.clone();
418 tokio::spawn(async move {
419 let mut stdin = tokio::io::BufWriter::new(stdin);
420 while let Some(line) = msg_rx.recv().await {
421 if let Err(e) = stdin.write_all(line.as_bytes()).await {
422 tracing::error!(block_id = %block_id_stdin, error = %e, "ACP stdin write error");
423 break;
424 }
425 if let Err(e) = stdin.write_all(b"\n").await {
426 tracing::error!(block_id = %block_id_stdin, error = %e, "ACP stdin newline error");
427 break;
428 }
429 if let Err(e) = stdin.flush().await {
430 tracing::error!(block_id = %block_id_stdin, error = %e, "ACP stdin flush error");
431 break;
432 }
433 }
434 });
435
436 let block_id_stdout = self.block_id.clone();
438 let broker_clone = self.broker.clone();
439 let filestore_clone = self.filestore.clone();
440 let inner_clone = self.inner.clone();
441 let health_clone = self.health_monitor.clone();
442 let rpc_id_clone = self.next_rpc_id.clone();
443 let outstanding_prompt_ids_clone = self.outstanding_prompt_ids.clone();
444 let mstore_clone = self.mstore.clone();
445 let event_bus_clone = self.event_bus.clone();
446 let global_output_zone =
448 super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
449 tokio::spawn(async move {
450 let mut reader = BufReader::new(stdout).lines();
451 tracing::info!(block_id = %block_id_stdout, "ACP stdout_reader started");
452
453 loop {
454 let line = match reader.next_line().await {
455 Err(e) => {
456 tracing::warn!(block_id = %block_id_stdout, error = %e, "ACP stdout read error");
457 break;
458 }
459 Ok(None) => {
460 tracing::info!(block_id = %block_id_stdout, "ACP stdout EOF");
461 break;
462 }
463 Ok(Some(l)) => l,
464 };
465 if line.is_empty() {
466 continue;
467 }
468 let mut recovered_error = false;
472 let mut replaying = false;
474 let mut turn_ended_by_error = false;
478 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
480 if let Some(reply) = reply_to_agent_request(&json) {
483 let inner = inner_clone.lock().unwrap();
484 if let Some(ref tx) = inner.stdin_tx {
485 if tx.try_send(reply.to_string()).is_err() {
486 tracing::warn!(block_id = %block_id_stdout, "[acp] reply to an agent request dropped — channel full or closed");
487 }
488 }
489 }
490 let msg_id = json.get("id").and_then(|v| v.as_u64());
495 let mut loaded: Option<String> = None;
496 {
497 let mut inner = inner_clone.lock().unwrap();
498 let is = |id: Option<u64>| msg_id.is_some() && msg_id == id;
499 replaying = is_session_replay(inner.loading_session.is_some(), is(inner.session_request_id));
500 let next = if is(inner.init_request_id) && json.get("result").is_some() {
501 inner.init_request_id = None;
502 let can_load = json
503 .pointer("/result/agentCapabilities/loadSession")
504 .and_then(|v| v.as_bool())
505 .unwrap_or(false);
506 let resume = inner.resume_session_id.clone();
507 let loading = if can_load { resume.clone() } else { None };
508 Some((session_request(resume.as_deref(), can_load, &inner.session_cwd), loading))
509 } else if is(inner.session_request_id) && json.get("error").is_some() && inner.loading_session.is_some() {
510 inner.loading_session = None;
511 recovered_error = true;
512 Some((session_request(None, false, &inner.session_cwd), None))
513 } else {
514 None
515 };
516 if is(inner.session_request_id) && json.get("result").is_some() {
517 loaded = inner.loading_session.take();
518 }
519 if next.is_none() && is(inner.init_request_id) && json.get("error").is_some() {
520 inner.init_request_id = None;
523 inner.pending_prompt = None;
524 inner.session_failed = true;
525 turn_ended_by_error = true;
526 }
527 if next.is_none() && is(inner.session_request_id) && json.get("error").is_some() {
528 inner.pending_prompt = None;
531 inner.session_failed = true;
532 turn_ended_by_error = true;
533 }
534 if let Some(((method, params), loading)) = next {
535 let id = rpc_id_clone.fetch_add(1, Ordering::Relaxed);
536 inner.session_request_id = Some(id);
537 inner.loading_session = loading;
538 let req = serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
539 if let Some(ref tx) = inner.stdin_tx {
540 if tx.try_send(req).is_err() {
541 tracing::error!(block_id = %block_id_stdout, method, "[acp] session request dropped — channel full or closed; agent will not start");
542 }
543 }
544 }
545 }
546 let session = json
547 .pointer("/result/sessionId")
548 .and_then(|v| v.as_str())
549 .map(str::to_string)
550 .or(loaded);
551 if let Some(sid_owned) = session {
552 {
553 let sid = sid_owned.as_str();
554 {
555 let mut inner = inner_clone.lock().unwrap();
556 inner.session_id = Some(sid_owned.clone());
557 if let Some(prompt) = inner.pending_prompt.take() {
559 let id = rpc_id_clone.fetch_add(1, Ordering::Relaxed);
560 outstanding_prompt_ids_clone.lock().unwrap().insert(id);
561 health_clone.mark_turn_active_returning_was_active();
574 if let Some(ref broker) = broker_clone {
575 let status = agent_runtime_status(
576 &block_id_stdout,
577 inner.status_version,
578 &inner.proc_status,
579 inner.proc_exit_code,
580 true,
581 );
582 super::publish_controller_status(broker, &status);
583 }
584 let req = serde_json::json!({
585 "jsonrpc": "2.0",
586 "id": id,
587 "method": "session/prompt",
588 "params": prompt_params(sid, &prompt),
589 }).to_string();
590 if let Some(ref tx) = inner.stdin_tx {
591 if tx.try_send(req).is_err() {
592 tracing::warn!(block_id = %block_id_stdout, "[acp] session/prompt send failed — channel full or closed");
593 let now_empty = {
601 let mut outstanding = outstanding_prompt_ids_clone.lock().unwrap();
602 outstanding.remove(&id);
603 outstanding.is_empty()
604 };
605 if now_empty {
606 health_clone.set_active_turn(false);
607 if let Some(ref broker) = broker_clone {
608 let status = agent_runtime_status(
609 &block_id_stdout,
610 inner.status_version,
611 &inner.proc_status,
612 inner.proc_exit_code,
613 false,
614 );
615 super::publish_controller_status(broker, &status);
616 }
617 }
618 }
619 }
620 }
621 }
622 tracing::info!(
623 block_id = %block_id_stdout,
624 session_id = %sid_owned,
625 "ACP session established"
626 );
627 core::persist_session_id(&block_id_stdout, &sid_owned, &mstore_clone, &event_bus_clone);
633 }
634 }
635
636 let resolved_id = if json.get("id").is_some()
651 && json.get("result").and_then(|r| r.get("stopReason")).is_some()
652 {
653 json.get("id").and_then(|v| v.as_u64())
654 } else if json.get("id").is_some() && json.get("error").is_some() {
655 json.get("id").and_then(|v| v.as_u64())
656 } else {
657 None
658 };
659 if let Some(resolved_id) = resolved_id {
660 let turn_is_over = {
661 let mut outstanding = outstanding_prompt_ids_clone.lock().unwrap();
662 resolve_prompt_and_check_idle(&mut outstanding, resolved_id)
663 };
664 if turn_is_over && json.get("error").is_some() {
665 turn_ended_by_error = true;
666 }
667 if turn_is_over {
668 health_clone.set_active_turn(false);
669 if let Some(ref broker) = broker_clone {
689 let status = {
690 let locked = inner_clone.lock().unwrap();
691 agent_runtime_status(
692 &block_id_stdout,
693 locked.status_version,
694 &locked.proc_status,
695 locked.proc_exit_code,
696 false,
697 )
698 };
699 super::publish_controller_status(broker, &status);
700 }
701 }
702 }
703 }
704
705 if recovered_error {
707 tracing::info!(block_id = %block_id_stdout, "[acp] session/load refused; opening a new session");
708 } else if replaying {
709 } else if let Some(ref broker) = broker_clone {
712 let line_with_newline = format!("{}\n", line);
713 super::shell::handle_append_block_file(
714 broker,
715 &block_id_stdout,
716 ACP_OUTPUT_SUBJECT,
717 line_with_newline.as_bytes(),
718 filestore_clone.as_ref(),
719 global_output_zone.as_deref(),
720 );
721 if turn_ended_by_error {
722 let end = format!("{}\n", TURN_ENDED_BY_ERROR);
723 super::shell::handle_append_block_file(
724 broker,
725 &block_id_stdout,
726 ACP_OUTPUT_SUBJECT,
727 end.as_bytes(),
728 filestore_clone.as_ref(),
729 global_output_zone.as_deref(),
730 );
731 }
732 }
733 }
734 });
735
736 let generation = self.begin_process();
738 let block_id_wait = self.block_id.clone();
739 let inner_wait = self.inner.clone();
740 let broker_wait = self.broker.clone();
741 let health_wait = self.health_monitor.clone();
742 let outstanding_prompt_ids_wait = self.outstanding_prompt_ids.clone();
743 tokio::spawn(async move {
744 tokio::select! {
745 _ = kill_rx => {
746 let _ = child.kill().await;
747 tracing::info!(block_id = %block_id_wait, "ACP process killed");
748
749 let mut inner = inner_wait.lock().unwrap();
750 if inner.generation != generation {
751 return;
753 }
754 inner.stdin_tx = None;
755 inner.current_pid = None;
756 AcpController::set_status(&mut inner, STATUS_DONE);
757 drop(inner);
758
759 health_wait.set_active_turn(false);
760 outstanding_prompt_ids_wait.lock().unwrap().clear();
774
775 if let Some(ref broker) = broker_wait {
776 let status = agent_runtime_status(&block_id_wait, 0, STATUS_DONE, -1, false);
777 super::publish_controller_status(broker, &status);
778 }
779 }
780 status = child.wait() => {
781 let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
782 tracing::info!(
783 block_id = %block_id_wait,
784 exit_code = exit_code,
785 "ACP process exited"
786 );
787 let mut inner = inner_wait.lock().unwrap();
788 if inner.generation != generation {
789 return;
791 }
792 inner.proc_exit_code = exit_code;
793 inner.stdin_tx = None;
794 AcpController::set_status(&mut inner, STATUS_DONE);
795 drop(inner);
796
797 health_wait.set_active_turn(false);
798 outstanding_prompt_ids_wait.lock().unwrap().clear();
801
802 if let Some(ref broker) = broker_wait {
803 let status = agent_runtime_status(&block_id_wait, 0, STATUS_DONE, exit_code, false);
804 super::publish_controller_status(broker, &status);
805 }
806 }
807 }
808 });
809
810 let init_id = self.next_id();
813 let init_req = serde_json::json!({
814 "jsonrpc": "2.0",
815 "id": init_id,
816 "method": "initialize",
817 "params": initialize_params(),
818 })
819 .to_string();
820 let mut inner = self.inner.lock().unwrap();
821 inner.init_request_id = Some(init_id);
822 inner.session_cwd = session_cwd(&working_dir);
823 if let Some(ref tx) = inner.stdin_tx {
824 if tx.try_send(init_req).is_err() {
825 tracing::error!(block_id = %self.block_id, "[acp] initialize dropped — channel full or closed; agent will not start");
826 }
827 }
828
829 Ok(())
830 }
831}
832
833fn meta_string_list(meta: &super::super::obj::MetaMapType, key: &str) -> Vec<String> {
835 match meta.get(key) {
836 Some(serde_json::Value::Array(values)) => values
837 .iter()
838 .filter_map(|v| v.as_str().map(str::to_string))
839 .collect(),
840 Some(serde_json::Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
841 _ => Vec::new(),
842 }
843}
844
845
846impl AcpController {
847 fn begin_process(&self) -> u64 {
852 self.outstanding_prompt_ids.lock().unwrap().clear();
853 self.health_monitor.set_active_turn(false);
854 let mut inner = self.inner.lock().unwrap();
855 inner.generation += 1;
856 inner.generation
857 }
858
859 pub fn ensure_started(&self, block_meta: super::super::obj::MetaMapType) -> Result<(), String> {
863 let _one_at_a_time = self.start_lock.lock().unwrap();
864 if self.is_running() {
865 return Ok(());
866 }
867 Controller::start(self, block_meta, None, false)
868 }
869
870 pub fn send_message(&self, message: String, message_id: Option<&str>) -> Result<(), String> {
874 Controller::send_input(self, BlockInputUnion::data(message.into_bytes()), None)?;
875 if let (Some(id), Some(broker)) = (message_id, self.broker.as_ref()) {
876 super::publish_message_accepted(broker, &self.block_id, id);
877 }
878 Ok(())
879 }
880
881 fn spawn_env(&self, block_meta: &super::super::obj::MetaMapType) -> HashMap<String, String> {
886 let mut env_vars = meta_string_map(block_meta, super::META_KEY_CMD_ENV);
887 if let Some(mstore) = &self.mstore {
888 crate::server::agent_handlers::input::carry_block_identity_env(
889 mstore,
890 &self.block_id,
891 &mut env_vars,
892 );
893 }
894 crate::server::agent_handlers::input::carry_agent_workdir_env(&mut env_vars, block_meta);
896 crate::server::browser_uploads::record_launch_workspace(&self.block_id, &env_vars);
897 crate::backend::gh_guard::apply_gh_guard(&mut env_vars);
898 crate::backend::account_login_guard::strip_account_login(&mut env_vars);
899 env_vars
900 }
901}
902
903impl Controller for AcpController {
904 fn start(
905 &self,
906 block_meta: super::super::obj::MetaMapType,
907 _rt_opts: Option<serde_json::Value>,
908 _force: bool,
909 ) -> Result<(), String> {
910 let cmd = super::super::obj::meta_get_string(&block_meta, super::META_KEY_CMD, "");
912 let cwd = super::super::obj::meta_get_string(&block_meta, super::META_KEY_CMD_CWD, "");
913 if cmd.is_empty() {
914 return Err("ACP controller: no cmd specified in block meta".to_string());
915 }
916
917 let args = meta_string_list(&block_meta, super::META_KEY_CMD_ARGS);
921 let resume = super::super::obj::meta_get_string(&block_meta, super::core::META_SESSION_ID, "");
922 {
923 let mut inner = self.inner.lock().unwrap();
926 inner.resume_session_id = Some(resume).filter(|s| !s.is_empty());
927 inner.session_id = None;
928 inner.init_request_id = None;
929 inner.session_request_id = None;
930 inner.loading_session = None;
931 inner.session_failed = false;
932 }
933 let mut env_vars = self.spawn_env(&block_meta);
934 if let Some(pi) = crate::backend::providers::pi_beside_pi_acp(&cmd) {
935 env_vars.entry("PI_ACP_PI_COMMAND".to_string()).or_insert(pi);
936 }
937
938 self.spawn_process(cmd, args, cwd, env_vars)
939 }
940
941 fn stop(&self, _graceful: bool, _new_status: &str) -> Result<(), String> {
942 {
944 let inner = self.inner.lock().unwrap();
945 if let Some(ref tx) = inner.stdin_tx {
946 let shutdown = self.make_request("shutdown", serde_json::json!({}));
947 let exit = self.make_notification("exit", serde_json::json!({}));
948 for (label, msg) in [("shutdown", shutdown), ("exit", exit)] {
949 if tx.try_send(msg).is_err() {
950 tracing::debug!(block_id = %self.block_id, method = label, "[acp] shutdown message dropped — process likely already gone");
951 }
952 }
953 }
954 }
955
956 let kill_tx = {
958 let mut inner = self.inner.lock().unwrap();
959 inner.stdin_tx = None;
960 inner.kill_tx.take()
961 };
962 if let Some(tx) = kill_tx {
963 let _ = tx.send(true);
964 }
965
966 {
967 let mut inner = self.inner.lock().unwrap();
968 Self::set_status(&mut inner, STATUS_DONE);
969 }
970 self.publish_status();
971 Ok(())
972 }
973
974 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
975 self.get_status_snapshot()
976 }
977
978 fn send_input(&self, input: BlockInputUnion, _seq: Option<u64>) -> Result<(), String> {
979 if let Some(data) = input.input_data {
980 let message = String::from_utf8_lossy(&data).to_string();
983 if message.trim().is_empty() {
984 return Ok(());
985 }
986
987 if !self.is_running() {
988 let mut inner = self.inner.lock().unwrap();
990 inner.pending_prompt = Some(message);
991 return Err("ACP process not running — message queued for next start()".to_string());
992 }
993
994 let session_id = {
995 let mut inner = self.inner.lock().unwrap();
996 match inner.session_id.clone() {
997 Some(sid) => sid,
998 None if inner.session_failed => {
999 return Err("The agent couldn't open a session (see the error above). Fix that, then restart the agent.".to_string());
1000 }
1001 None => {
1002 inner.pending_prompt = Some(match inner.pending_prompt.take() {
1008 Some(earlier) => format!("{earlier}\n\n{message}"),
1009 None => message,
1010 });
1011 return Ok(());
1012 }
1013 }
1014 };
1015 let prompt_id = self.next_id();
1022 self.outstanding_prompt_ids.lock().unwrap().insert(prompt_id);
1023 let req = serde_json::json!({
1024 "jsonrpc": "2.0",
1025 "id": prompt_id,
1026 "method": "session/prompt",
1027 "params": prompt_params(&session_id, &message),
1028 }).to_string();
1029 self.health_monitor.mark_turn_active_returning_was_active();
1047 self.publish_status();
1060
1061 let send_result = {
1062 let inner = self.inner.lock().unwrap();
1063 if let Some(ref tx) = inner.stdin_tx {
1064 tx.try_send(req)
1065 .map_err(|e| format!("ACP stdin send failed: {e}"))
1066 } else {
1067 Err("ACP process not running — stdin sender vanished after the initial check".to_string())
1078 }
1079 };
1080 if let Err(e) = send_result {
1081 let now_empty = {
1095 let mut outstanding = self.outstanding_prompt_ids.lock().unwrap();
1096 outstanding.remove(&prompt_id);
1097 outstanding.is_empty()
1098 };
1099 if now_empty {
1100 self.health_monitor.set_active_turn(false);
1101 self.publish_status();
1102 }
1103 return Err(e);
1104 }
1105 }
1106
1107 if let Some(sig) = input.sig_name {
1108 if sig == "SIGTERM" || sig == "SIGINT" {
1109 return self.stop(true, STATUS_DONE);
1110 }
1111 }
1112
1113 Ok(())
1114 }
1115
1116 fn turn_tracker(&self) -> Option<Arc<TurnActivityTracker>> {
1117 Some(Arc::clone(&self.health_monitor))
1118 }
1119
1120 fn controller_type(&self) -> &str {
1121 BLOCK_CONTROLLER_ACP
1122 }
1123
1124 fn block_id(&self) -> &str {
1125 &self.block_id
1126 }
1127
1128 fn as_any(&self) -> &dyn std::any::Any {
1129 self
1130 }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135
1136 #[test]
1137 fn initialize_states_protocol_version_1_and_offers_no_fs_or_terminal() {
1138 let p = super::initialize_params();
1139 assert_eq!(p["protocolVersion"], 1);
1140 assert_eq!(p["clientCapabilities"]["fs"]["readTextFile"], false);
1141 assert_eq!(p["clientCapabilities"]["terminal"], false);
1142 assert_eq!(p["clientInfo"]["name"], "AgentMux");
1143 }
1144
1145 #[test]
1146 fn a_session_is_loaded_only_when_the_agent_can_and_there_is_one() {
1147 let (m, p) = super::session_request(Some("s1"), true, "C:/w");
1148 assert_eq!(m, "session/load");
1149 assert_eq!(p, serde_json::json!({ "sessionId": "s1", "cwd": "C:/w", "mcpServers": [] }));
1150 assert_eq!(super::session_request(Some("s1"), false, "C:/w").0, "session/new");
1151 let (m, p) = super::session_request(None, true, "C:/w");
1152 assert_eq!(m, "session/new");
1153 assert_eq!(p, serde_json::json!({ "cwd": "C:/w", "mcpServers": [] }));
1154 }
1155
1156 #[test]
1157 fn the_prompt_is_an_array_of_content_blocks() {
1158 assert_eq!(
1159 super::prompt_params("s1", "hi"),
1160 serde_json::json!({ "sessionId": "s1", "prompt": [{ "type": "text", "text": "hi" }] })
1161 );
1162 }
1163
1164 #[test]
1165 fn permission_requests_are_approved_preferring_allow_always() {
1166 let req = |options: serde_json::Value| {
1167 serde_json::json!({ "jsonrpc": "2.0", "id": 7, "method": "session/request_permission",
1168 "params": { "sessionId": "s", "toolCall": { "toolCallId": "t" }, "options": options } })
1169 };
1170 let both = req(serde_json::json!([
1171 { "optionId": "once", "kind": "allow_once", "name": "Allow" },
1172 { "optionId": "always", "kind": "allow_always", "name": "Always" },
1173 { "optionId": "no", "kind": "reject_once", "name": "Reject" },
1174 ]));
1175 assert_eq!(
1176 super::reply_to_agent_request(&both).unwrap(),
1177 serde_json::json!({ "jsonrpc": "2.0", "id": 7, "result": { "outcome": { "outcome": "selected", "optionId": "always" } } })
1178 );
1179 let once = req(serde_json::json!([{ "optionId": "once", "kind": "allow_once", "name": "Allow" }]));
1180 assert_eq!(super::reply_to_agent_request(&once).unwrap()["result"]["outcome"]["optionId"], "once");
1181 let reject = req(serde_json::json!([{ "optionId": "no", "kind": "reject_once", "name": "Reject" }]));
1182 assert_eq!(
1183 super::reply_to_agent_request(&reject).unwrap()["result"]["outcome"],
1184 serde_json::json!({ "outcome": "cancelled" })
1185 );
1186 }
1187
1188 #[test]
1189 fn other_agent_requests_get_method_not_found_and_notifications_get_nothing() {
1190 let fs = serde_json::json!({ "jsonrpc": "2.0", "id": "a", "method": "fs/read_text_file", "params": {} });
1191 let reply = super::reply_to_agent_request(&fs).unwrap();
1192 assert_eq!(reply["id"], "a");
1193 assert_eq!(reply["error"]["code"], -32601);
1194 let note = serde_json::json!({ "jsonrpc": "2.0", "method": "session/update", "params": {} });
1195 assert!(super::reply_to_agent_request(¬e).is_none());
1196 let response = serde_json::json!({ "jsonrpc": "2.0", "id": 3, "result": {} });
1197 assert!(super::reply_to_agent_request(&response).is_none());
1198 }
1199
1200 #[test]
1207 fn an_acp_spawn_env_keeps_cmd_env_and_carries_the_rows_identity() {
1208 use crate::backend::storage::agents::test_agent_def;
1209 let store =
1210 std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1211 let mut def = test_agent_def("uid-acp", "AcpAgent", "openclaw", "agent", 1, "");
1212 store.agent_def_insert(&mut def).unwrap();
1213 let mut meta = super::super::super::obj::MetaMapType::new();
1214 meta.insert("agentId".to_string(), serde_json::json!("uid-acp"));
1215 meta.insert("cmd:env".to_string(), serde_json::json!({"K": "v"}));
1216 let mut block = crate::backend::obj::Block {
1217 oid: "block-acp".to_string(),
1218 parentoref: String::new(),
1219 version: 0,
1220 runtimeopts: None,
1221 stickers: None,
1222 meta: meta.clone(),
1223 subblockids: None,
1224 };
1225 store.insert(&mut block).unwrap();
1226 let ctrl = AcpController::new(
1227 "tab".to_string(),
1228 "block-acp".to_string(),
1229 None,
1230 None,
1231 Some(store.clone()),
1232 None,
1233 );
1234 let env = ctrl.spawn_env(&meta);
1235 assert_eq!(env.get("K").map(String::as_str), Some("v"));
1236 assert_eq!(
1237 env.get("AGENTMUX_AGENT_UID").map(String::as_str),
1238 Some("uid-acp")
1239 );
1240 assert_eq!(
1241 env.get("AGENTMUX_AGENT_TOKEN"),
1242 store.agent_token_load("uid-acp").unwrap().as_ref()
1243 );
1244 }
1245
1246 #[test]
1249 fn an_acp_spawn_env_overrides_gh_config_dir_with_the_guard_dir() {
1250 let mut meta = super::super::super::obj::MetaMapType::new();
1251 meta.insert(
1252 "cmd:env".to_string(),
1253 serde_json::json!({"GH_CONFIG_DIR": "/home/human/.config/gh", "AGENTMUX_AGENT_SLUG": "acpy"}),
1254 );
1255 let ctrl = AcpController::new("tab".to_string(), "block-acp-gh".to_string(), None, None, None, None);
1256 let env = ctrl.spawn_env(&meta);
1257 assert_eq!(
1258 std::path::PathBuf::from(&env["GH_CONFIG_DIR"]),
1259 crate::backend::gh_guard::guard_config_home().join("gh-acpy")
1260 );
1261 }
1262
1263 #[test]
1266 fn an_acp_spawn_env_carries_the_agent_workdir() {
1267 let ws = std::env::temp_dir().join("acpy-0930a");
1268 let mut meta = super::super::super::obj::MetaMapType::new();
1269 meta.insert("cmd:cwd".to_string(), serde_json::json!(ws.to_string_lossy()));
1270 meta.insert("cmd:env".to_string(), serde_json::json!({"AGENTMUX_AGENT_WORKDIR": "stale"}));
1271 let ctrl = AcpController::new("tab".to_string(), "block-acp-workdir".to_string(), None, None, None, None);
1272 assert_eq!(std::path::PathBuf::from(&ctrl.spawn_env(&meta)["AGENTMUX_AGENT_WORKDIR"]), ws);
1273 }
1274
1275 #[test]
1278 fn an_acp_spawn_env_never_carries_the_account_login() {
1279 let mut meta = super::super::super::obj::MetaMapType::new();
1280 meta.insert(
1281 "cmd:env".to_string(),
1282 serde_json::json!({"MUXBUS_TOKEN": "eyJ.stale", "MUXBUS_COGNITO_DOMAIN": "https://auth.example", "AGENTMUX_AGENT_SLUG": "acpy"}),
1283 );
1284 let ctrl = AcpController::new("tab".to_string(), "block-acp-login".to_string(), None, None, None, None);
1285 let env = ctrl.spawn_env(&meta);
1286 assert!(!env.contains_key("MUXBUS_TOKEN"), "{env:?}");
1287 assert!(!env.contains_key("MUXBUS_COGNITO_DOMAIN"), "{env:?}");
1288 }
1289
1290 #[test]
1291 fn acp_reads_cmd_args_and_env_as_arrays_objects_or_json_strings() {
1292 let mut meta = super::super::super::obj::MetaMapType::new();
1293 meta.insert("cmd:args".to_string(), serde_json::json!(["--acp", "x"]));
1294 meta.insert("cmd:env".to_string(), serde_json::json!({"K": "v"}));
1295 assert_eq!(
1296 super::meta_string_list(&meta, "cmd:args"),
1297 vec!["--acp", "x"]
1298 );
1299 assert_eq!(
1300 super::meta_string_map(&meta, "cmd:env")
1301 .get("K")
1302 .map(String::as_str),
1303 Some("v")
1304 );
1305
1306 meta.insert("cmd:args".to_string(), serde_json::json!("[\"--acp\"]"));
1307 meta.insert("cmd:env".to_string(), serde_json::json!("{\"K\":\"w\"}"));
1308 assert_eq!(super::meta_string_list(&meta, "cmd:args"), vec!["--acp"]);
1309 assert_eq!(
1310 super::meta_string_map(&meta, "cmd:env")
1311 .get("K")
1312 .map(String::as_str),
1313 Some("w")
1314 );
1315
1316 assert!(super::meta_string_list(&meta, "absent").is_empty());
1317 assert!(super::meta_string_map(&meta, "absent").is_empty());
1318 }
1319
1320 use super::*;
1321
1322 fn controller() -> AcpController {
1323 AcpController::new("tab".to_string(), "block".to_string(), None, None, None, None)
1324 }
1325
1326 #[test]
1330 fn resolve_prompt_and_check_idle_reports_empty_after_the_only_outstanding_prompt_resolves() {
1331 let mut outstanding = HashSet::from([3]);
1332 assert!(
1333 resolve_prompt_and_check_idle(&mut outstanding, 3),
1334 "resolving the only outstanding prompt must report the turn as over"
1335 );
1336 assert!(outstanding.is_empty());
1337 }
1338
1339 #[test]
1344 fn resolve_prompt_and_check_idle_stays_active_while_a_newer_steering_prompt_is_still_outstanding() {
1345 let mut outstanding = HashSet::from([1, 2]);
1346 assert!(
1347 !resolve_prompt_and_check_idle(&mut outstanding, 1),
1348 "the newer (2) prompt is still outstanding — the turn must not end yet"
1349 );
1350 assert_eq!(outstanding, HashSet::from([2]));
1351 }
1352
1353 #[test]
1362 fn resolve_prompt_and_check_idle_ends_the_turn_when_a_later_rejection_resolves_the_last_outstanding_prompt() {
1363 let mut outstanding = HashSet::from([1, 2]);
1364 assert!(!resolve_prompt_and_check_idle(&mut outstanding, 1), "prompt 2 is still outstanding");
1366 assert!(
1369 resolve_prompt_and_check_idle(&mut outstanding, 2),
1370 "the last outstanding prompt resolving (even via rejection) must end the turn"
1371 );
1372 assert!(outstanding.is_empty());
1373 }
1374
1375 #[test]
1379 fn resolve_prompt_and_check_idle_is_a_harmless_no_op_for_an_untracked_id() {
1380 let mut outstanding: HashSet<u64> = HashSet::new();
1381 assert!(resolve_prompt_and_check_idle(&mut outstanding, 999));
1382 assert!(outstanding.is_empty());
1383 }
1384
1385 #[tokio::test]
1393 async fn a_message_before_the_session_exists_is_queued_not_sent() {
1394 let c = controller();
1395 let (tx, mut rx) = mpsc::channel::<String>(8);
1396 c.inner.lock().unwrap().stdin_tx = Some(tx);
1397 assert!(c.send_input(BlockInputUnion::data(b"startup".to_vec()), None).is_ok());
1398 assert!(c.send_input(BlockInputUnion::data(b"second".to_vec()), None).is_ok());
1399 assert!(rx.try_recv().is_err(), "nothing goes to the agent before the session exists");
1400 assert_eq!(c.inner.lock().unwrap().pending_prompt.as_deref(), Some("startup\n\nsecond"));
1401 assert!(!c.health_monitor.is_active_turn());
1402 }
1403
1404 #[tokio::test]
1407 async fn a_send_after_the_session_was_refused_is_an_error() {
1408 let c = controller();
1409 let (tx, _rx) = mpsc::channel::<String>(8);
1410 {
1411 let mut inner = c.inner.lock().unwrap();
1412 inner.stdin_tx = Some(tx);
1413 inner.session_failed = true;
1414 }
1415 let err = c.send_input(BlockInputUnion::data(b"hi".to_vec()), None).unwrap_err();
1416 assert!(err.contains("couldn't open a session"), "{err}");
1417 assert!(c.inner.lock().unwrap().pending_prompt.is_none());
1418 }
1419
1420 #[tokio::test]
1423 async fn ensure_started_only_starts_a_stopped_agent() {
1424 let c = controller();
1425 let (tx, _rx) = mpsc::channel::<String>(8);
1426 c.inner.lock().unwrap().stdin_tx = Some(tx);
1427 assert!(c.ensure_started(Default::default()).is_ok(), "running: nothing to do");
1428 c.inner.lock().unwrap().stdin_tx = None;
1429 let err = c.ensure_started(Default::default()).unwrap_err();
1430 assert!(err.contains("no cmd"), "stopped: it tries to start: {err}");
1431 }
1432
1433 #[tokio::test]
1434 async fn a_new_process_forgets_the_old_ones_unanswered_prompts() {
1435 let c = controller();
1436 c.outstanding_prompt_ids.lock().unwrap().insert(41);
1437 c.health_monitor.set_active_turn(true);
1438 let first = c.begin_process();
1439 assert!(c.outstanding_prompt_ids.lock().unwrap().is_empty());
1440 assert!(!c.health_monitor.is_active_turn());
1441 assert_eq!(c.begin_process(), first + 1);
1442 }
1443
1444 #[test]
1447 fn a_null_request_id_is_echoed() {
1448 let req = serde_json::json!({ "jsonrpc": "2.0", "id": null, "method": "fs/read_text_file", "params": {} });
1449 let reply = super::reply_to_agent_request(&req).unwrap();
1450 assert!(reply["id"].is_null());
1451 assert_eq!(reply["error"]["code"], -32601);
1452 }
1453
1454 #[tokio::test]
1457 async fn a_stale_process_waiter_leaves_a_restart_alone() {
1458 let c = controller();
1459 let (tx, _rx) = mpsc::channel::<String>(8);
1460 {
1461 let mut inner = c.inner.lock().unwrap();
1462 inner.stdin_tx = Some(tx);
1463 inner.generation = 2;
1464 }
1465 let stale = 1;
1467 let cleaned = {
1468 let mut inner = c.inner.lock().unwrap();
1469 if inner.generation != stale { false } else { inner.stdin_tx = None; true }
1470 };
1471 assert!(!cleaned);
1472 assert!(c.is_running(), "the new process stays connected");
1473 }
1474
1475 #[test]
1476 fn the_session_directory_is_never_empty() {
1477 let abs = std::env::current_dir().unwrap().join("work").join("a");
1478 assert_eq!(super::session_cwd(&abs.to_string_lossy()), abs.to_string_lossy());
1479 let rel = super::session_cwd("work/a");
1481 assert!(std::path::Path::new(&rel).is_absolute(), "{rel}");
1482 assert!(rel.ends_with("a"), "{rel}");
1483 let fallback = super::session_cwd("");
1484 assert!(!fallback.is_empty());
1485 assert!(std::path::Path::new(&fallback).is_absolute(), "{fallback}");
1486 }
1487
1488 #[test]
1489 fn lines_during_a_session_load_are_replay_except_the_loads_own_answer() {
1490 assert!(super::is_session_replay(true, false));
1491 assert!(!super::is_session_replay(true, true));
1492 assert!(!super::is_session_replay(false, false));
1493 }
1494
1495 #[tokio::test]
1496 async fn send_input_marks_the_turn_active() {
1497 let c = controller();
1498 let (tx, _rx) = mpsc::channel::<String>(8);
1502 {
1503 let mut inner = c.inner.lock().unwrap();
1504 inner.stdin_tx = Some(tx);
1505 inner.session_id = Some("s1".to_string());
1506 }
1507
1508 assert!(!c.health_monitor.is_active_turn());
1509 let res = c.send_input(BlockInputUnion::data(b"hello".to_vec()), None);
1510 assert!(res.is_ok(), "send_input should succeed against a simulated running process, got {res:?}");
1511 assert!(c.health_monitor.is_active_turn(), "send_input must mark the turn active");
1512 }
1513
1514 #[tokio::test]
1520 async fn repeated_send_input_while_active_does_not_error() {
1521 let c = controller();
1522 let (tx, _rx) = mpsc::channel::<String>(8);
1523 {
1524 let mut inner = c.inner.lock().unwrap();
1525 inner.stdin_tx = Some(tx);
1526 inner.session_id = Some("s1".to_string());
1527 }
1528
1529 assert!(c.send_input(BlockInputUnion::data(b"first".to_vec()), None).is_ok());
1530 assert!(c.send_input(BlockInputUnion::data(b"second".to_vec()), None).is_ok());
1531 }
1532
1533 #[tokio::test]
1546 async fn send_input_publishes_the_turn_active_flip() {
1547 let broker = Arc::new(mps::Broker::new());
1548 let c = AcpController::new(
1549 "tab".to_string(),
1550 "block-acp-publish".to_string(),
1551 Some(broker.clone()),
1552 None,
1553 None,
1554 None,
1555 );
1556 let (tx, _rx) = mpsc::channel::<String>(8);
1557 {
1558 let mut inner = c.inner.lock().unwrap();
1559 inner.stdin_tx = Some(tx);
1560 inner.session_id = Some("s1".to_string());
1561 }
1562
1563 assert!(c.send_input(BlockInputUnion::data(b"hello".to_vec()), None).is_ok());
1564
1565 let history = broker.read_event_history(
1566 mps::EVENT_CONTROLLER_STATUS,
1567 "block:block-acp-publish",
1568 1,
1569 );
1570 assert_eq!(history.len(), 1, "send_input must publish a controllerstatus event");
1571 let published: BlockControllerRuntimeStatus =
1572 serde_json::from_value(history[0].data.clone().unwrap()).unwrap();
1573 assert!(published.turn_active, "published status must reflect the just-started turn as active");
1574 assert!(c.health_monitor.is_active_turn());
1575 }
1576
1577 #[tokio::test]
1590 async fn send_input_rolls_back_turn_active_when_enqueue_fails() {
1591 let broker = Arc::new(mps::Broker::new());
1592 let c = AcpController::new(
1593 "tab".to_string(),
1594 "block-acp-enqueue-fail".to_string(),
1595 Some(broker.clone()),
1596 None,
1597 None,
1598 None,
1599 );
1600 let (tx, rx) = mpsc::channel::<String>(8);
1601 drop(rx); {
1603 let mut inner = c.inner.lock().unwrap();
1604 inner.stdin_tx = Some(tx);
1605 inner.session_id = Some("s1".to_string());
1606 }
1607
1608 let res = c.send_input(BlockInputUnion::data(b"hello".to_vec()), None);
1609 assert!(res.is_err(), "send_input should surface the enqueue failure, got {res:?}");
1610 assert!(!c.health_monitor.is_active_turn(), "must not leave the turn marked active when the enqueue itself failed");
1611
1612 let history = broker.read_event_history(
1618 mps::EVENT_CONTROLLER_STATUS,
1619 "block:block-acp-enqueue-fail",
1620 1,
1621 );
1622 assert_eq!(history.len(), 1, "send_input must publish the rollback so live subscribers see the corrected state");
1623 let published: BlockControllerRuntimeStatus =
1624 serde_json::from_value(history[0].data.clone().unwrap()).unwrap();
1625 assert!(!published.turn_active, "the latest published status must reflect the rollback (turn_active: false)");
1626 }
1627
1628 #[tokio::test]
1644 async fn send_input_does_not_roll_back_an_already_active_turn_on_a_failed_steering_send() {
1645 let c = controller();
1646 let (tx, _rx) = mpsc::channel::<String>(8);
1647 {
1648 let mut inner = c.inner.lock().unwrap();
1649 inner.stdin_tx = Some(tx);
1650 inner.session_id = Some("s1".to_string());
1651 }
1652
1653 assert!(c.send_input(BlockInputUnion::data(b"first".to_vec()), None).is_ok());
1655 assert!(c.health_monitor.is_active_turn());
1656 let outstanding_after_first: Vec<u64> = c.outstanding_prompt_ids.lock().unwrap().iter().copied().collect();
1657 assert_eq!(outstanding_after_first.len(), 1, "exactly the first prompt's id must be outstanding");
1658 let first_prompt_id = outstanding_after_first[0];
1659
1660 let (tx2, rx2) = mpsc::channel::<String>(8);
1663 drop(rx2);
1664 c.inner.lock().unwrap().stdin_tx = Some(tx2);
1665
1666 let res = c.send_input(BlockInputUnion::data(b"second".to_vec()), None);
1667 assert!(res.is_err(), "the steering send's own enqueue should fail, got {res:?}");
1668 assert!(c.health_monitor.is_active_turn(), "the turn genuinely active from the FIRST send must not be rolled back by the second send's own failure");
1669
1670 let outstanding_after_failure = c.outstanding_prompt_ids.lock().unwrap().clone();
1671 assert_eq!(
1672 outstanding_after_failure,
1673 HashSet::from([first_prompt_id]),
1674 "only the still-in-flight FIRST prompt's id must remain outstanding — the failed second one must not linger"
1675 );
1676 }
1677}