Skip to main content

agentmux_srv/backend/blockcontroller/
acp.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! AcpController: manages agent CLIs that speak the Agent Client Protocol (ACP).
5//!
6//! ACP is a JSON-RPC 2.0 protocol over stdin/stdout — the "LSP for AI agents."
7//! Instead of custom per-provider output parsing, ACP provides a standardized
8//! protocol for session management, prompting, and streaming events.
9//!
10//! Lifecycle:
11//!   1. Spawn the ACP agent process (e.g., `gemini --acp`, `openclaw acp`)
12//!   2. Send `initialize` request, receive capabilities
13//!   3. Send `initialized` notification
14//!   4. Create a session via `session/create`
15//!   5. For each user turn: send `session/prompt`, stream `session/update` notifications
16//!   6. On close: send `shutdown` + `exit`
17//!
18//! I/O model (similar to PersistentSubprocessController):
19//!   - stdin_writer: sends JSON-RPC requests/notifications to agent
20//!   - stdout_reader: reads JSON-RPC responses/notifications, persists + broadcasts
21//!   - process_waiter: monitors process lifecycle
22//!
23//! See: https://github.com/agentclientprotocol/agent-client-protocol
24
25#[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
46/// MPS file subject name for ACP output.
47pub const ACP_OUTPUT_SUBJECT: &str = "output";
48
49pub const BLOCK_CONTROLLER_ACP: &str = "acp";
50
51/// Written to the output after an error that ended the turn (no prompt left
52/// running, or the session couldn't open). The translator ends a turn on a
53/// `stopReason`; it can't tell from an error alone, which may answer only a
54/// mid-turn steering prompt while the first one still runs.
55const TURN_ENDED_BY_ERROR: &str = r#"{"jsonrpc":"2.0","result":{"stopReason":"error"}}"#;
56
57// ---- ACP v1 messages (SPEC_ACP_CLIENT_CONFORMANCE_2026_10_07.md) ----
58// Checked against @agentclientprotocol/sdk 0.26.0 (`PROTOCOL_VERSION = 1`).
59
60/// `initialize`: the protocol version and what this client can do. AgentMux
61/// offers no file system or terminal to the agent; it runs its own tools.
62fn 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
73/// `session/new`, or `session/load` of `resume` when the agent can load one.
74fn 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
84/// Whether a line from the agent is part of a `session/load` replay: the
85/// agent streams the earlier conversation as `session/update`s before it
86/// answers the load, and the load's own answer is not one of them.
87fn is_session_replay(loading: bool, is_session_response: bool) -> bool {
88    loading && !is_session_response
89}
90
91/// The absolute directory ACP's `session/new` and `session/load` take as
92/// `cwd`: the pane's working directory (`~` expanded; a relative one resolved
93/// against the server's, as `Command::current_dir` resolves it), else the
94/// directory the agent process itself starts in.
95fn 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
106/// `session/prompt` params: the prompt is an array of content blocks.
107fn 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
114/// The reply to a request the agent sends us, if `json` is one (it has both
115/// `method` and `id`). AgentMux approves tool use by default, as it does for
116/// every harness: the first `allow_always` option, else `allow_once`, else
117/// cancelled. Anything else (`fs/*`, `terminal/*`, which this client does not
118/// offer) gets "method not found", so the agent never waits on us.
119fn reply_to_agent_request(json: &serde_json::Value) -> Option<serde_json::Value> {
120    let method = json.get("method")?.as_str()?;
121    // A request carries an `id`, which ACP lets be null and requires us to
122    // echo; a notification has none.
123    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
149/// Remove `resolved_id` from the set of outstanding (sent, not yet
150/// resolved) prompts and report whether the set is now EMPTY — i.e.
151/// whether every prompt sent so far has now been resolved, by either a
152/// real `stopReason` completion or a JSON-RPC `error` rejection, meaning
153/// the turn has genuinely ended. Extracted as a pure function over a
154/// plain `HashSet` so this exact logic is directly unit-testable; call
155/// sites wrap it with the shared mutex around the real
156/// `outstanding_prompt_ids`.
157///
158/// Tracking every outstanding prompt — not just "the latest" — is what
159/// makes response ORDERING irrelevant: "is the turn over" only ever asks
160/// "is anything still outstanding," never "was this the most recent
161/// one." codex P2 on PR #2338 (thirtieth re-review) — superseded the
162/// twenty-seventh through twenty-ninth re-reviews' single latest/
163/// previous-id tracking (`is_latest_prompt_completion` +
164/// `rollback_latest_prompt_id_if_unchanged`, both removed), which could
165/// permanently strand `turn_active` at `true`: a steering prompt's
166/// rejection arriving AFTER the earlier prompt's completion had already
167/// been consumed (and discarded as "not the latest") rolled
168/// `latest_prompt_id` back to a prompt whose own completion would never
169/// arrive again — nothing left to ever satisfy the match.
170fn resolve_prompt_and_check_idle(outstanding: &mut HashSet<u64>, resolved_id: u64) -> bool {
171    outstanding.remove(&resolved_id);
172    outstanding.is_empty()
173}
174
175/// Inner state protected by mutex.
176struct 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    /// First user prompt, deferred until session/create completes.
185    pending_prompt: Option<String>,
186    /// ACP v1 handshake: the `initialize` request, whose result decides
187    /// between `session/new` and `session/load`.
188    init_request_id: Option<u64>,
189    /// The `session/new` or `session/load` request in flight.
190    session_request_id: Option<u64>,
191    /// The session being loaded: its result carries no id of its own.
192    loading_session: Option<String>,
193    /// `session/new` was refused (pi: "Authentication required"): no session
194    /// will open until the agent restarts.
195    session_failed: bool,
196    /// Counts spawns. A process's waiter cleans up the shared state only
197    /// while its generation is current, so one that finishes after a restart
198    /// can't disconnect the new process.
199    generation: u64,
200    /// The pane's previous session (`agent:sessionid`), offered to
201    /// `session/load` when the agent supports it.
202    resume_session_id: Option<String>,
203    /// The session's working directory.
204    session_cwd: String,
205}
206
207/// AcpController manages an ACP-speaking agent process.
208pub 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    /// Monotonically increasing JSON-RPC request ID.
219    next_rpc_id: Arc<AtomicU64>,
220    /// Every `session/prompt` request id that has been SENT but not yet
221    /// RESOLVED (by either a real `stopReason` completion or a JSON-RPC
222    /// `error` rejection). The turn is genuinely over only once this set
223    /// is empty — see `resolve_prompt_and_check_idle`'s doc comment for
224    /// why tracking the full set (not just "the latest" id) is necessary:
225    /// steering can leave multiple prompts outstanding at once, and
226    /// responses/rejections can arrive in any order relative to each
227    /// other and relative to new sends (independent stdin-writer and
228    /// stdout-reader tasks, no ordering guarantee between them). codex P2
229    /// on PR #2338 (twenty-seventh through thirtieth re-reviews).
230    outstanding_prompt_ids: Arc<Mutex<HashSet<u64>>>,
231    /// Serializes `ensure_started`, so two deliveries that find the agent
232    /// stopped can't both start it.
233    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    /// Build a JSON-RPC 2.0 request.
310    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    /// Build a JSON-RPC 2.0 notification (no id field).
321    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    /// Spawn the ACP agent process and perform the initialize handshake.
330    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        // Identity M4a: record what this process is actually given.
336        crate::backend::identity_spawn::record_process_spawn(
337            &self.block_id,
338            crate::backend::identity_spawn::SpawnPath::Acp,
339            &env_vars,
340        );
341        // On Windows: suppress console-window allocation. Without CREATE_NO_WINDOW,
342        // node.exe spawned from a windowless sidecar may try to create/attach to a
343        // console, causing stdout to go to that console rather than the pipe.
344        #[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        // In the block's tracker before it runs (`spawn_tracked`); closes the
354        // Process Broker Phase B gap for ACP agent panes
355        // (SPEC_PROCESS_BROKER_PHASE_B_SHELL_ACP_REGISTRATION_2026_07_31.md).
356        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        // Drain stderr with explicit error/EOF logging
379        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        // Stdin writer channel
405        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        // Spawn stdin writer task
417        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        // Spawn stdout reader task — reads NDJSON lines and broadcasts via MPS
437        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        // Resolve the agent's GLOBAL transcript zone once (see persistent/spawn.rs).
447        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                // A failed `session/load` the handshake recovers from (it
469                // opens a new session): not shown, or the pane reads it as
470                // an error that ended a turn.
471                let mut recovered_error = false;
472                // A line the agent sends while it replays a loaded session.
473                let mut replaying = false;
474                // An error that ended the turn: a turn end follows it (below),
475                // since the error itself doesn't say whether other prompts are
476                // still running.
477                let mut turn_ended_by_error = false;
478                // Parse as JSON to check for session/update notifications
479                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
480                    // A request from the agent (permission, fs, terminal):
481                    // answer it, or the agent waits forever.
482                    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                    // Handshake: `initialize` answered → open or load the
491                    // session; a failed `session/load` → open a new one. A
492                    // loaded session's result carries no id: it is the one
493                    // we asked for.
494                    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                            // initialize refused (an unsupported protocol
521                            // version): no session will ever open.
522                            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                            // session/new refused (pi: "Authentication
529                            // required"): the queued prompt can't run.
530                            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                                // Flush pending prompt now that session is ready.
558                                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                                    // Mark the turn active + publish, mirroring
562                                    // send_input's identical pattern — without
563                                    // this, a prompt flushed here (queued before
564                                    // the controller finished starting) is
565                                    // invisible to is_active_turn()/wasTurnActive:
566                                    // a concurrently-deferred /login controller
567                                    // refresh could then read
568                                    // isBackendTurnConfirmedIdle() as true and
569                                    // force-restart the controller while this
570                                    // flushed prompt is genuinely in flight.
571                                    // reagentx P2 on PR #2338 (thirty-first
572                                    // re-review).
573                                    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                                            // Mirror send_input's enqueue-failure
594                                            // rollback: this prompt was never
595                                            // really sent, so it must not be
596                                            // left occupying the outstanding set
597                                            // or leave the turn marked active
598                                            // when nothing else is genuinely
599                                            // outstanding.
600                                            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                            // Persist to block metadata and broadcast so the frontend's
628                            // "My Agents" reattach path can read agent:sessionid from
629                            // block.meta. ACP previously captured the ID in memory only —
630                            // this mirrors the careful path from persistent/stdout_reader.rs /
631                            // subprocess/host_spawn.rs.
632                            core::persist_session_id(&block_id_stdout, &sid_owned, &mstore_clone, &event_bus_clone);
633                        }
634                    }
635
636                    // A prompt is RESOLVED — one way or another — by either
637                    // a real `stopReason` completion or a JSON-RPC `error`
638                    // rejection (mid-turn prompt acceptance is agent-
639                    // dependent; some agents reject a steering prompt sent
640                    // while an earlier one is still being processed).
641                    // Either way, remove it from the outstanding set and
642                    // only declare the turn genuinely over once NOTHING is
643                    // left outstanding — never based on "was this the most
644                    // recently sent prompt," which breaks under steering:
645                    // an earlier prompt's completion can arrive after a
646                    // newer one was sent, or a newer prompt's rejection can
647                    // arrive after an earlier one already completed. codex
648                    // P2 on PR #2338 (twenty-seventh through thirtieth
649                    // re-reviews).
650                    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                            // Publish the flip so live controllerstatus
670                            // subscribers see "turn ended" immediately,
671                            // mirroring persistent/stdout_reader.rs's matching publish
672                            // on its own normal (non-kill, non-exit)
673                            // turn-end path. Without this, nothing
674                            // publishes turn_active: false for an ACP
675                            // controller (OpenClaw/Copilot/Pi in
676                            // catalog.ts) until kill or process exit —
677                            // a normal turn-end here left the frontend's
678                            // `wasTurnActive` signal (fed only by live
679                            // controllerstatus events, never local
680                            // state) stuck at its last-seen value,
681                            // stranding useAgentCommands.ts's
682                            // flushPendingControllerRefresh (which
683                            // requires isBackendTurnConfirmedIdle —
684                            // wasTurnActive === false — before running a
685                            // /login-deferred controller restart) until
686                            // an unrelated event happened to republish
687                            // status (#2338).
688                            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                // Persist + broadcast via the shared helper (same as subprocess/host_spawn.rs)
706                if recovered_error {
707                    tracing::info!(block_id = %block_id_stdout, "[acp] session/load refused; opening a new session");
708                } else if replaying {
709                    // Earlier turns the agent replays while loading: the pane
710                    // already has them, and their tool-call ids would collide.
711                } 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        // Spawn process waiter task
737        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                        // A newer process owns the controller now.
752                        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                    // resync_controller (mod.rs) can REUSE this exact
761                    // AcpController instance across a kill+restart cycle
762                    // (needs_replace false, status STATUS_DONE — the
763                    // non-forced pane-reopen path, or after watchdog.rs's
764                    // max-runtime/idle-timeout stop) — outstanding_prompt_ids
765                    // is the SAME Arc across that reuse. Any prompt ids still
766                    // outstanding at kill time will never get a response (no
767                    // process left to answer), so without clearing here they
768                    // would linger forever: the NEXT turn's own prompt id
769                    // resolving would never empty the set, permanently
770                    // stranding turn_active at true for the rest of this
771                    // instance's lifetime. reagentx P1 on PR #2338
772                    // (thirty-third re-review).
773                    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                        // A newer process owns the controller now.
790                        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                    // See the matching comment in the kill_rx branch above —
799                    // same reasoning applies to a natural process exit.
800                    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        // ACP v1 handshake: `initialize` only. Its result decides between
811        // `session/new` and `session/load`; the stdout reader sends that.
812        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
833/// A meta value that is a string array, or a JSON string encoding one.
834fn 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    /// A new process takes over the controller: the generation moves on, and
848    /// the prompts the previous process never answered are forgotten. Its
849    /// waiter, which sees the new generation, no longer clears them; left
850    /// there they would keep the turn active for good.
851    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    /// Start the agent again if its process has exited, from the pane's
860    /// meta; a no-op when it is running. One start at a time: a second
861    /// delivery waits here and then finds it running.
862    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    /// A user message from `agentinput` / `agent.send` (`run_agent_turn`):
871    /// sent as `session/prompt`, then acknowledged the way the other
872    /// controllers acknowledge theirs, so the pane stops showing it as pending.
873    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    /// The env this block's ACP process is spawned with: `cmd:env` (either
882    /// shape), with the block's row UID + token carried onto it — identity
883    /// M4b-2. Buys no attribution today (ACP agents are not given
884    /// agentmux-mcp) but keeps the counters true.
885    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        // The workspace the "Your workspace" Operator Config entry points at.
895        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        // Extract spawn config from block metadata
911        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        // `cmd:args` / `cmd:env` as `agent.open` stores them (an array and an
918        // object) or as a JSON string — before M4b-2 only the string form was
919        // read, so an `agent.open` launch lost both (spec §6.5.8).
920        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            // A restart (the agent exited) starts a new handshake: a prompt
924            // must wait for the new session, never go to the old one.
925            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        // Send shutdown request before killing
943        {
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        // Kill the process
957        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            // Raw input from the frontend — treat as a user message.
981            // The frontend sends the user prompt as UTF-8 bytes.
982            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                // Process not running — stash as pending so start() picks it up.
989                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                        // The handshake hasn't opened the session yet (the
1003                        // startup message is sent right after launch): queue
1004                        // it; the stdout reader sends it once the session
1005                        // exists. Sent with no session id, the agent would
1006                        // reject it and the message would be lost.
1007                        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            // Not built via make_request: the id must be captured so it can
1016            // be tracked as outstanding (see outstanding_prompt_ids's doc
1017            // comment) — make_request only returns the serialized string.
1018            // Inserted BEFORE the enqueue below (not after), for the same
1019            // happens-before reason mark_turn_active_returning_was_active
1020            // is called before the enqueue — see that call's own comment.
1021            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            // `mark_turn_active_returning_was_active` (not the plain
1030            // `set_active_turn(true)` this used to call) is atomic across
1031            // the read-and-write, so a mid-turn steering send racing this
1032            // one can't both observe "was idle" — see persistent/input.rs's
1033            // identical guard.
1034            //
1035            // Established BEFORE the stdin enqueue below (not after) — a
1036            // fast-responding ACP agent can have the stdout reader task
1037            // observe and publish the resulting `stopReason` (turn_active:
1038            // false) on a DIFFERENT tokio worker before this thread would
1039            // otherwise reach this point, if the enqueue ran first. Only
1040            // ordering it this way guarantees this mark happens-before the
1041            // request can possibly be enqueued, sent, and answered. codex
1042            // P2 on PR #2338 (twenty-sixth re-review) — superseded the
1043            // prior (twenty-fifth re-review) ordering, which fixed a
1044            // different bug (see the rollback below) by moving this after
1045            // the enqueue, reintroducing this race.
1046            self.health_monitor.mark_turn_active_returning_was_active();
1047            // Publish the turn_active flip, mirroring persistent/queue.rs's
1048            // send_message and persistent/input.rs's send_user_message
1049            // (which both call self.publish_status() right after the same
1050            // atomic turn-active mark). Without this,
1051            // wasTurnActive === false left over from an EARLIER turn's
1052            // end-of-turn publish (or the initial spawn publish) is
1053            // indistinguishable from genuine current idleness — a `/login`
1054            // that defers a controller refresh during THIS turn, followed
1055            // by a premature frontend Done transition, would see
1056            // isBackendTurnConfirmedIdle() read true from that stale
1057            // signal and force-restart the controller, killing the
1058            // actually-active turn (#2338).
1059            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                    // stdin_tx can vanish between the is_running() check
1068                    // above and this re-lock — e.g. a concurrent stop() or
1069                    // controller resync clearing it in between. Treating
1070                    // this as success (the old behavior) silently lost the
1071                    // message (never actually enqueued anywhere) while
1072                    // still marking the turn active and leaving prompt_id
1073                    // permanently outstanding — nothing will ever resolve
1074                    // it. Handling it identically to a try_send failure
1075                    // reuses the existing rollback below. codex P1 on
1076                    // PR #2338 (thirty-fourth re-review).
1077                    Err("ACP process not running — stdin sender vanished after the initial check".to_string())
1078                }
1079            };
1080            if let Err(e) = send_result {
1081                // try_send can fail (channel full, or the stdin writer task
1082                // already exited because the process died) — no turn
1083                // actually starts for THIS prompt. Remove it from the
1084                // outstanding set (it was never really sent) and only
1085                // declare the turn over if NOTHING else is left outstanding
1086                // — a turn ALREADY active from an earlier successful send
1087                // (mid-turn steering) must not be clobbered by this failed
1088                // one. This single emptiness check replaces what used to be
1089                // two separate, easy-to-desync mechanisms: a `was_active`-
1090                // conditioned rollback, and a latest/previous-id
1091                // compare-and-swap that could still strand turn_active
1092                // under out-of-order responses (codex P2 on PR #2338,
1093                // twenty-seventh through thirtieth re-reviews).
1094                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(&note).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    /// Identity M4b-2 (spec §6.5.8): `agent.open` stores `cmd:args` as an
1201    /// array and `cmd:env` as an object; ACP read only JSON strings, so
1202    /// such a launch lost both. Both shapes now read the same.
1203    /// Identity M4b-2 wiring (spec §6.5.8): an ACP controller with a store
1204    /// spawns a row-backed block with `agent.open`'s object `cmd:env` kept
1205    /// and the row's UID + token carried onto it.
1206    #[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    /// Plain-`gh` guard on the ACP spawn path: `GH_CONFIG_DIR` from `cmd:env`
1247    /// is replaced by the guard dir, not passed through.
1248    #[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    /// A host ACP agent is told where its workspace is, like every other
1264    /// host spawn path (`carry_agent_workdir_env`).
1265    #[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    /// No agent env carries the account's cloud login, even from a
1276    /// `cmd:env` persisted before `account_login_guard` existed.
1277    #[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    /// Regression for codex P2 on PR #2338 (twenty-seventh/thirtieth
1327    /// re-reviews): the single-prompt case — one prompt sent, one
1328    /// response — must still correctly declare the turn over.
1329    #[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    /// Regression for codex P2 on PR #2338 (twenty-seventh re-review): an
1340    /// EARLIER prompt's response arriving after a steering/new prompt was
1341    /// already sent must not end the turn while the newer one is still
1342    /// outstanding.
1343    #[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    /// Regression for codex P2 on PR #2338 (thirtieth re-review) — the
1354    /// exact scenario the single latest/previous-id tracking (removed)
1355    /// could not handle: the EARLIER prompt completes and is resolved
1356    /// FIRST (while a steering prompt is still outstanding), and the
1357    /// steering prompt is REJECTED afterward. The turn must end once that
1358    /// rejection resolves the LAST remaining outstanding prompt — not get
1359    /// permanently stuck because the earlier prompt's completion was
1360    /// already "consumed."
1361    #[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        // The earlier prompt (1) completes first.
1365        assert!(!resolve_prompt_and_check_idle(&mut outstanding, 1), "prompt 2 is still outstanding");
1366        // The steering prompt (2) is REJECTED afterward — this must now
1367        // correctly end the turn, since nothing else is outstanding.
1368        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    /// A response for an id that was never tracked (or already resolved) —
1376    /// e.g. a duplicate delivery — must not panic; HashSet::remove is a
1377    /// harmless no-op for a non-member.
1378    #[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    /// `send_input` marks the turn active via `mark_turn_active_returning_was_active`
1386    /// — pins the call-site contract that `turn_active` flips true once a
1387    /// message is actually sent to an already-running process.
1388    /// A message sent before the handshake has opened the session (the
1389    /// startup message, sent right after launch) is queued for the stdout
1390    /// reader to send once the session exists, never sent with an empty
1391    /// session id (#4447).
1392    #[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    /// After `session/new` is refused nothing will ever open a session, so a
1405    /// send is an error the user sees, not a message queued for good.
1406    #[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    /// A running agent is left alone; a stopped one is started from the pane's
1421    /// meta (here with none, so the start itself reports it).
1422    #[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    /// ACP lets a request id be null and requires the reply to echo it; only a
1445    /// message with no `id` at all is a notification.
1446    #[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    /// The previous process's waiter finishing after a restart must not clear
1455    /// the new process's state.
1456    #[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        // What the waiter of generation 1 checks before cleaning up.
1466        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        // A relative one resolves against the server's directory.
1480        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        // Simulate an already-running process — send_input only reaches the
1499        // turn-active logic when `is_running()` is true (otherwise it
1500        // stashes the message as `pending_prompt` for the next start()).
1501        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    /// A second send while already active (mid-turn steering) must not
1515    /// error or panic — `mark_turn_active_returning_was_active`'s
1516    /// was-already-active branch is what gates re-arming; this at least
1517    /// confirms the call site doesn't choke on repeated calls the way a
1518    /// naive re-check-then-act would.
1519    #[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    /// Regression for codex P1 on PR #2338 (twenty-third re-review):
1534    /// `send_input` marked the turn active via `health_monitor` but never
1535    /// published the flip — the only controllerstatus publishes for ACP
1536    /// were on spawn, kill, or process exit. A `wasTurnActive === false`
1537    /// left over from an EARLIER turn's end-of-turn publish (or the initial
1538    /// spawn publish) was therefore indistinguishable from genuine current
1539    /// idleness: useAgentCommands.ts's `isBackendTurnConfirmedIdle()` (fed
1540    /// only by live controllerstatus events) would read stale-true and let
1541    /// `flushPendingControllerRefresh` force-restart a controller that is
1542    /// actually mid-turn. Mirrors persistent/queue.rs's send_message and
1543    /// persistent/input.rs's send_user_message, which both call
1544    /// `publish_status()` right after the same atomic turn-active mark.
1545    #[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    /// Regression for codex P2 on PR #2338 (twenty-fifth re-review), as
1578    /// refined by codex P2 on PR #2338 (twenty-sixth re-review):
1579    /// `send_input` marks the turn active and publishes BEFORE attempting
1580    /// `tx.try_send(req)` (established ordering, not after — see the
1581    /// twenty-sixth re-review's race fix below) — if the enqueue itself
1582    /// fails (channel full, or the stdin writer task already exited
1583    /// because the process died), no turn actually started, so the mark
1584    /// and publish must be ROLLED BACK rather than left standing. A
1585    /// rejected prompt left standing as "active" could re-promote the
1586    /// frontend to a working state and clear auth-recovery UI via
1587    /// notifyControllerHealthy, and the health watchdog would be armed for
1588    /// work that never happened.
1589    #[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); // Receiver gone — try_send fails with TrySendError::Closed.
1602        {
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        // The state is briefly marked active (established BEFORE the
1613        // enqueue attempt to close the twenty-sixth re-review's race), then
1614        // rolled back once the enqueue failure is discovered — so the
1615        // LATEST published status must reflect the rollback, not the
1616        // transient true a live subscriber may have also observed.
1617        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    /// Regression for codex P2 on PR #2338 (twenty-sixth re-review): a
1629    /// mid-turn steering send (turn already genuinely active from an
1630    /// EARLIER successful send) whose OWN enqueue then fails must not roll
1631    /// back and clobber that still-active turn — the FIRST prompt's id
1632    /// must remain outstanding.
1633    ///
1634    /// Also covers reagent P1 (twenty-eighth re-review) and codex P2
1635    /// (twenty-ninth/thirtieth re-reviews): the failed second prompt's id
1636    /// must NOT remain in the outstanding set either (it was never really
1637    /// sent) — outstanding_prompt_ids's set-based tracking (superseding
1638    /// the single latest/previous-id fields those rounds patched
1639    /// incrementally) makes both of these hold simultaneously without any
1640    /// special-cased rollback logic: removing the failed id and checking
1641    /// emptiness is the ONLY rule, and it's correct regardless of send/
1642    /// response ordering.
1643    #[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        // First send succeeds — turn is now genuinely active.
1654        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        // Swap in a closed channel so the SECOND (steering) send's own
1661        // enqueue fails.
1662        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}