Skip to main content

luft_adapters/
acp_adapter.rs

1//! `AcpAdapter` — drives an `opencode acp` subprocess as an ACP **client**.
2//!
3//! One [`AgentBackend::run`] call = one one-shot ACP session: spawn opencode,
4//! `initialize` → `session/new` → `session/prompt`, stream `session/update`
5//! notifications into Luft progress events, then collect the result.
6//!
7//! ## Threading
8//! The `agent-client-protocol` connection future is `!Send` (it drives a
9//! `LocalSet`), but [`AgentBackend::run`] is `#[async_trait]` and therefore
10//! `Send`. We bridge by running the whole session on a dedicated current-thread
11//! runtime + `LocalSet` inside `spawn_blocking`, returning the `Send` result.
12//!
13//! ## Structure
14//! [`run_acp_session`] is the orchestrator and delegates each phase to a
15//! dedicated helper:
16//!  1. [`spawn_agent`] — fork `opencode acp` and wire up stdin/stdout.
17//!  2. (inline) — assemble shared `Arc`s and channels.
18//!  3. [`prepare_schema_mcp`] — write a temp JSON Schema file when an
19//!     `output_schema` is present.
20//!  4. [`drive_connection`] — build the `Client` builder and wire up
21//!     notification/permission handlers.
22//!  5. [`run_handshake_and_prompt`] — `initialize` → `session/new` (with
23//!     optional `set_config_option`) → `session/prompt`.
24//!  6. [`collect_session_result`] — assemble the final [`AgentResult`].
25
26use super::{permission, result_collector, update_mapper};
27use luft_core::contract::backend::{
28    AgentBackend, AgentCapabilities, AgentResult, AgentTask, BackendError, RunContext, ToolPolicy,
29};
30use luft_core::contract::event::EventSender;
31#[cfg(feature = "unstable_end_turn_token_usage")]
32use luft_core::contract::ids::TokenUsage;
33use luft_core::contract::ids::{AgentId, RunId};
34use async_trait::async_trait;
35use std::path::PathBuf;
36use std::process::Stdio;
37use std::sync::{Arc, Mutex};
38use std::time::Duration;
39use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
40
41/// The byte-stream transport used between Luft and the ACP subprocess.
42type AcpTransport =
43    ByteStreams<Compat<tokio::process::ChildStdin>, Compat<tokio::process::ChildStdout>>;
44
45use agent_client_protocol::schema::{
46    ContentBlock, InitializeRequest, McpServer, McpServerStdio, NewSessionRequest,
47    NewSessionResponse, PromptRequest, ProtocolVersion, RequestPermissionOutcome,
48    RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
49    SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionId,
50    SessionNotification, SetSessionConfigOptionRequest, StopReason, TextContent,
51};
52use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
53
54/// Default idle timeout: if the ACP agent sends no protocol notification
55/// for this duration, the session is killed.
56const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
57
58/// After a `structured_output` submission is captured, the agent has this long
59/// to stop generating before the session is force-killed. The timer does NOT
60/// reset on further activity — once the LLM has submitted its result, any
61/// additional tool calls after that point are dropped and the session is
62/// closed. This bounds the wait when the LLM continues producing tool calls
63/// (e.g. `todo_write`) after submitting (see
64/// `docs/issues/opengui-stories-2026-07-06-stuck-run.md` §3.3).
65const POST_SUBMISSION_IDLE: Duration = Duration::from_secs(5);
66
67/// Stable PascalCase spelling of `StopReason::EndTurn` — used when synthesizing
68/// a stop reason after a post-submission timeout so the stored value matches
69/// what [`stop_reason_as_str`] would have produced for a real `EndTurn`
70/// response. This is the **single source of truth** for the synthesized
71/// spelling; do not inline `"EndTurn"` elsewhere.
72const STOP_REASON_END_TURN: &str = "EndTurn";
73
74/// What caused `idle_watchdog` to return.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum WatchdogOutcome {
77    /// LLM hung for `pre_idle` with no protocol activity and no submission.
78    PreIdleTimeout,
79    /// LLM submitted a valid `structured_output` but kept generating for
80    /// `post_idle` after the submission. Caller should treat the captured
81    /// submission as the result.
82    PostSubmissionTimeout,
83    /// All activity senders were dropped (channel closed). Treat as a clean
84    /// shutdown.
85    ChannelClosed,
86}
87
88/// Bundled state used by the ACP connection handlers. Holding these as a
89/// single struct keeps the closure call sites thin and easy to scan.
90struct SessionState {
91    acc: Arc<update_mapper::Accumulator>,
92    stop_holder: Arc<Mutex<Option<String>>>,
93    events: EventSender,
94    activity_tx: tokio::sync::mpsc::UnboundedSender<()>,
95    activity_rx: tokio::sync::mpsc::UnboundedReceiver<()>,
96    submit_signal: Arc<tokio::sync::Notify>,
97    run_id: RunId,
98    agent_id: AgentId,
99    emit_raw: bool,
100    policy: Option<ToolPolicy>,
101    prompt: String,
102    cwd: PathBuf,
103}
104
105/// ACP backend configuration.
106#[derive(Debug, Clone)]
107pub struct AcpConfig {
108    /// Backend identifier (returned by `AgentBackend::id`).
109    pub id: &'static str,
110    /// Agent binary; resolved from `PATH`. Defaults to `opencode`.
111    pub binary: PathBuf,
112    /// Extra arguments passed to the agent binary (e.g. `["acp"]`).
113    pub acp_args: Vec<String>,
114    /// Optional `--log-level` passed to the agent.
115    pub log_level: Option<String>,
116    /// `initialize` handshake timeout.
117    pub connect_timeout: Duration,
118    /// Emit verbatim ACP `session/update` notifications as
119    /// [`AgentEvent::AcpRaw`](luft_core::contract::event::AgentEvent::AcpRaw).
120    /// On by default; the journal does not persist them (see `docs/design/acp-raw-events.md`).
121    pub emit_raw_events: bool,
122    /// Explicit allowlist of environment variable NAMES forwarded to the
123    /// ACP subprocess. The parent process env is **not** inherited by default.
124    ///
125    /// Each name is looked up via `std::env::var` at spawn time; missing
126    /// entries are silently skipped. Set to `vec![]` to forward **no**
127    /// environment at all (subprocess starts with a fully empty env).
128    ///
129    /// AI provider credentials (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...)
130    /// and the generic `MODEL` env var are deliberately excluded from the
131    /// default — provider/model selection must come from the subprocess's
132    /// own config files (`auth.json`, `config.json`) or explicit CLI
133    /// arguments, not from the host shell. This is a security and
134    /// reproducibility boundary: subprocess behavior does not silently
135    /// change because the user happens to have a stray env var set.
136    ///
137    /// Default: [`AcpConfig::DEFAULT_ENV_PASSTHROUGH`] — the minimum set
138    /// needed for the binary to bootstrap on the current OS (PATH, user
139    /// dirs, temp, locale, shell).
140    pub env_passthrough: Vec<String>,
141    /// Model to use for LLM calls. Passed via ACP `session/set_config_option`
142    /// with category `model`. If the agent does not support model selection,
143    /// this is silently ignored.
144    pub model: Option<String>,
145}
146
147impl AcpConfig {
148    /// Environment variables forwarded by default. The minimum set needed
149    /// for a subprocess to bootstrap on the current OS — **no** AI provider
150    /// keys, no `MODEL`, no arbitrary shell state. Extend or override via
151    /// [`AcpConfig::env_passthrough`].
152    pub const DEFAULT_ENV_PASSTHROUGH: &'static [&'static str] = &[
153        // OS / loader
154        "PATH",
155        "SYSTEMROOT",
156        "WINDIR",
157        "COMSPEC",
158        "PATHEXT",
159        // User / home
160        "USERPROFILE",
161        "HOME",
162        "USER",
163        "USERNAME",
164        "LOGNAME",
165        // Temp
166        "TMPDIR",
167        "TMP",
168        "TEMP",
169        // Locale
170        "LANG",
171        "LC_ALL",
172        "LC_CTYPE",
173        // Shell (sometimes needed for spawned sub-shells)
174        "SHELL",
175    ];
176}
177
178impl Default for AcpConfig {
179    fn default() -> Self {
180        Self {
181            id: "opencode",
182            binary: PathBuf::from("opencode"),
183            acp_args: vec!["acp".to_string()],
184            log_level: None,
185            connect_timeout: Duration::from_secs(10),
186            emit_raw_events: true,
187            env_passthrough: Self::DEFAULT_ENV_PASSTHROUGH
188                .iter()
189                .map(|s| s.to_string())
190                .collect(),
191            model: None,
192        }
193    }
194}
195
196/// ACP client backend for `opencode` (and compatible ACP agents).
197pub struct AcpAdapter {
198    config: AcpConfig,
199}
200
201impl AcpAdapter {
202    pub fn new(config: AcpConfig) -> Self {
203        Self { config }
204    }
205
206    /// Convenience constructor for the default `opencode acp` backend.
207    pub fn default_opencode() -> Self {
208        Self::new(AcpConfig::default())
209    }
210
211    /// Returns the configuration this adapter was built with. Useful for
212    /// introspection / diagnostics and for tests that need to distinguish
213    /// adapter instances beyond their [`AgentBackend::id`].
214    pub fn config(&self) -> &AcpConfig {
215        &self.config
216    }
217}
218
219#[async_trait]
220impl AgentBackend for AcpAdapter {
221    fn id(&self) -> &'static str {
222        self.config.id
223    }
224
225    fn capabilities(&self) -> AgentCapabilities {
226        AgentCapabilities {
227            streaming: true,
228            mcp_injection: true,
229            structured_output: true,
230            models: vec![],
231        }
232    }
233
234    fn as_any(&self) -> &dyn std::any::Any {
235        self
236    }
237
238    async fn run(&self, task: AgentTask, ctx: RunContext) -> Result<AgentResult, BackendError> {
239        let config = self.config.clone();
240        let cancel = ctx.cancel.clone();
241        let events = ctx.events.clone();
242        let run_id = ctx.run_id;
243
244        // The ACP connection future is !Send → run it on its own current-thread
245        // runtime + LocalSet, off the shared worker pool.
246        let handle = tokio::task::spawn_blocking(move || {
247            let rt = tokio::runtime::Builder::new_current_thread()
248                .enable_all()
249                .build()
250                .map_err(|e| BackendError::Execution(format!("acp runtime: {e}")))?;
251            let local = tokio::task::LocalSet::new();
252            local.block_on(&rt, run_acp_session(config, task, run_id, cancel, events))
253        });
254
255        handle
256            .await
257            .map_err(|e| BackendError::Execution(format!("acp task join: {e}")))?
258    }
259}
260
261/// Orchestrator. Each numbered phase is delegated to a named helper below.
262#[tracing::instrument(
263    name = "backend",
264    skip_all,
265    fields(run_id = %run_id, agent_id = %task.agent_id, backend = "opencode")
266)]
267async fn run_acp_session(
268    config: AcpConfig,
269    task: AgentTask,
270    run_id: RunId,
271    cancel: tokio_util::sync::CancellationToken,
272    events: EventSender,
273) -> Result<AgentResult, BackendError> {
274    // 1. Spawn the agent subprocess and build the byte-stream transport.
275    let (mut child, transport) = spawn_agent(&config)?;
276
277    // 2. Shared state: accumulator, stop-reason slot, activity channel, and
278    //    the submission `Notify` that flips the idle watchdog into
279    //    post-submission mode. The `submit_signal` is `Arc`d so both the
280    //    watchdog (kept here) and the notification handler (inside the
281    //    connection builder) can hold a handle.
282    let (activity_tx, activity_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
283    let submit_signal = Arc::new(tokio::sync::Notify::new());
284    let mut state = SessionState {
285        acc: Arc::new(update_mapper::Accumulator::new()),
286        stop_holder: Arc::new(Mutex::new(None)),
287        events: events.clone(),
288        activity_tx,
289        activity_rx,
290        submit_signal: submit_signal.clone(),
291        run_id,
292        agent_id: task.agent_id,
293        emit_raw: config.emit_raw_events,
294        policy: task.allowlist.clone(),
295        prompt: task.prompt.clone(),
296        cwd: std::fs::canonicalize(&task.workdir).unwrap_or_else(|_| task.workdir.clone()),
297    };
298
299    // 3. Optional structured-output MCP server: serialise the JSON Schema to
300    //    a temp file so the `luft mcp-structured-output` subprocess can
301    //    validate the agent's final payload.
302    let schema_guard = prepare_schema_mcp(task.output_schema.as_ref())?;
303    let schema_file_path = schema_guard
304        .as_ref()
305        .map(|g| g.0.path().to_string_lossy().into_owned());
306
307    // 4. Build the connection future, then race it against cancel + idle
308    //    timeout. The watchdog's `submit_signal` is a clone of the one
309    //    inside `state` so `notify_one()` from the notification handler
310    //    reaches both.
311    let conn_fut = drive_connection(&state, transport, schema_file_path, config.model.clone());
312    let idle_timeout = task.timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT);
313
314    // 5. Race the session against cancellation + idle timeout.
315    //    The idle timeout resets on every ACP notification, so a long-running
316    //    tool execution (with streaming updates) won't be killed — only a
317    //    truly silent/hung agent will time out.
318    let outcome = tokio::select! {
319        r = conn_fut => r,
320        _ = cancel.cancelled() => {
321            tracing::debug!("ACP session cancelled");
322            let _ = child.start_kill();
323            return Err(BackendError::Cancelled);
324        }
325        res = idle_watchdog(
326            idle_timeout,
327            POST_SUBMISSION_IDLE,
328            &mut state.activity_rx,
329            state.submit_signal.clone(),
330        ) => {
331            handle_watchdog_outcome(res, &mut child, &state.stop_holder, idle_timeout)?;
332            Ok(())
333        }
334    };
335    let _ = child.start_kill();
336
337    outcome.map_err(classify_protocol_error)?;
338
339    // 6. Collect the final result from the accumulator + stop-reason slot.
340    Ok(collect_session_result(&task, &state))
341}
342
343// ─── Phase 1: spawn ────────────────────────────────────────────────────────
344
345/// Build the `Command` for `opencode acp`, spawn it, and wrap its stdio in a
346/// `ByteStreams` transport suitable for the ACP client.
347///
348/// SECURITY / REPRODUCIBILITY: the parent process env is **never** inherited.
349/// Only the explicit allowlist in [`AcpConfig::env_passthrough`] is forwarded.
350/// This prevents accidental leakage of shell-level API keys and makes spawn
351/// behavior deterministic across host configurations.
352fn spawn_agent(config: &AcpConfig) -> Result<(tokio::process::Child, AcpTransport), BackendError> {
353    let mut cmd = tokio::process::Command::new(&config.binary);
354    cmd.args(&config.acp_args);
355    if let Some(level) = &config.log_level {
356        cmd.arg("--log-level").arg(level);
357    }
358    cmd.stdin(Stdio::piped())
359        .stdout(Stdio::piped())
360        .stderr(Stdio::null());
361
362    cmd.env_clear();
363    for name in &config.env_passthrough {
364        if let Ok(value) = std::env::var(name) {
365            cmd.env(name, value);
366        }
367    }
368
369    let mut child = cmd.spawn().map_err(|e| {
370        tracing::error!(binary = %config.binary.display(), error = %e, "failed to spawn ACP backend");
371        BackendError::Spawn(format!("failed to spawn {}: {e}", config.binary.display()))
372    })?;
373    let stdin = child
374        .stdin
375        .take()
376        .ok_or_else(|| BackendError::Spawn("no child stdin".into()))?;
377    let stdout = child
378        .stdout
379        .take()
380        .ok_or_else(|| BackendError::Spawn("no child stdout".into()))?;
381    let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
382    Ok((child, transport))
383}
384
385// ─── Phase 3: schema MCP ────────────────────────────────────────────────────
386
387/// If a JSON Schema was supplied, serialise it to a temp file and return a
388/// guard that deletes the file when dropped. The file path is later injected
389/// into the `session/new` request as the `--schema-file` arg of a
390/// `luft mcp-structured-output` subprocess.
391fn prepare_schema_mcp(
392    schema: Option<&serde_json::Value>,
393) -> Result<Option<SchemaFileGuard>, BackendError> {
394    let Some(schema) = schema else {
395        return Ok(None);
396    };
397    let schema_json = serde_json::to_string(schema)
398        .map_err(|e| BackendError::Execution(format!("schema serialize: {e}")))?;
399    let schema_file = tempfile::NamedTempFile::new()
400        .map_err(|e| BackendError::Execution(format!("schema temp file: {e}")))?;
401    std::fs::write(&schema_file, &schema_json)
402        .map_err(|e| BackendError::Execution(format!("schema temp write: {e}")))?;
403    let path = schema_file.path().to_string_lossy().into_owned();
404    tracing::debug!(schema_file = %path, "prepared MCP structured-output server");
405    Ok(Some(SchemaFileGuard(schema_file)))
406}
407
408struct SchemaFileGuard(tempfile::NamedTempFile);
409
410// ─── Phase 4: drive the connection ──────────────────────────────────────────
411
412/// Build the `Client` connection, attaching notification and permission
413/// handlers and the handshake+prompt driver as the `main_fn`.
414fn drive_connection(
415    state: &SessionState,
416    transport: AcpTransport,
417    schema_file_path: Option<String>,
418    model: Option<String>,
419) -> impl std::future::Future<Output = Result<(), agent_client_protocol::Error>> {
420    let acc = state.acc.clone();
421    let events = state.events.clone();
422    let stop_holder = state.stop_holder.clone();
423    let activity_tx = state.activity_tx.clone();
424    let submit_signal = state.submit_signal.clone();
425    let run_id = state.run_id;
426    let agent_id = state.agent_id;
427    let emit_raw = state.emit_raw;
428    let policy = state.policy.clone();
429
430    let acc_for_prompt = acc.clone();
431    let stop_holder_for_prompt = stop_holder.clone();
432    let cwd = state.cwd.clone();
433    let prompt = state.prompt.clone();
434
435    async move {
436        Client
437            .builder()
438            .name("luft")
439            .on_receive_notification(
440                {
441                    let acc = acc.clone();
442                    let events = events.clone();
443                    let activity_tx = activity_tx.clone();
444                    let submit_signal = submit_signal.clone();
445                    move |n: SessionNotification, _cx: ConnectionTo<Agent>| {
446                        let acc = acc.clone();
447                        let events = events.clone();
448                        let activity_tx = activity_tx.clone();
449                        let submit_signal = submit_signal.clone();
450                        async move {
451                            handle_session_update(
452                                n,
453                                &acc,
454                                &events,
455                                &activity_tx,
456                                &submit_signal,
457                                run_id,
458                                agent_id,
459                                emit_raw,
460                            );
461                            Ok(())
462                        }
463                    }
464                },
465                agent_client_protocol::on_receive_notification!(),
466            )
467            .on_receive_request(
468                {
469                    let policy = policy.clone();
470                    move |req: RequestPermissionRequest,
471                          responder: Responder<RequestPermissionResponse>,
472                          _conn: ConnectionTo<Agent>| {
473                        let policy = policy.clone();
474                        async move { decide_permission(req, responder, policy).await }
475                    }
476                },
477                agent_client_protocol::on_receive_request!(),
478            )
479            .connect_with(transport, move |conn: ConnectionTo<Agent>| {
480                let acc_for_prompt = acc_for_prompt.clone();
481                let stop_holder_for_prompt = stop_holder_for_prompt.clone();
482                let model = model.clone();
483                async move {
484                    run_handshake_and_prompt(
485                        &conn,
486                        &cwd,
487                        schema_file_path.as_deref(),
488                        model.as_deref(),
489                        &prompt,
490                        &acc_for_prompt,
491                        &stop_holder_for_prompt,
492                    )
493                    .await
494                }
495            })
496            .await
497    }
498}
499
500/// Notification handler body. Updates the accumulator, emits a progress event,
501/// and signals the watchdog the moment a `structured_output` payload is first
502/// captured (None → Some transition).
503#[allow(clippy::too_many_arguments)]
504fn handle_session_update(
505    n: SessionNotification,
506    acc: &Arc<update_mapper::Accumulator>,
507    events: &EventSender,
508    activity_tx: &tokio::sync::mpsc::UnboundedSender<()>,
509    submit_signal: &Arc<tokio::sync::Notify>,
510    run_id: RunId,
511    agent_id: AgentId,
512    emit_raw: bool,
513) {
514    let _ = activity_tx.send(());
515    let kind = serde_json::to_value(&n.update)
516        .ok()
517        .and_then(|v| {
518            v.get("sessionUpdate")
519                .and_then(|v| v.as_str())
520                .map(String::from)
521        })
522        .unwrap_or_else(|| "unknown".to_string());
523    tracing::debug!(%kind, "ACP session/update");
524
525    // Capture pre-update submission state so we can detect the None → Some
526    // transition on `structured_output` and signal the idle watchdog to switch
527    // to a short, non-resetting timer.
528    let was_submitted = acc.structured_output.lock().unwrap().is_some();
529    update_mapper::handle_update(&n.update, run_id, agent_id, acc, events, emit_raw);
530    if !was_submitted && acc.structured_output.lock().unwrap().is_some() {
531        submit_signal.notify_one();
532        tracing::debug!(
533            "ACP structured_output captured; watchdog switching to post-submission mode"
534        );
535    }
536}
537
538/// Permission-request decision. Approves via the task's [`ToolPolicy`]
539/// (falling back to approve when no policy is set), then selects the first
540/// offered option — `request_permission` is non-interactive in v0.1.
541async fn decide_permission(
542    req: RequestPermissionRequest,
543    responder: Responder<RequestPermissionResponse>,
544    policy: Option<ToolPolicy>,
545) -> Result<(), agent_client_protocol::Error> {
546    let inputs = permission::extract_inputs(&req);
547    let approve = matches!(
548        permission::decide(policy.as_ref(), &inputs),
549        permission::Decision::Approve
550    );
551    tracing::debug!(
552        approve,
553        options = req.options.len(),
554        "ACP permission request"
555    );
556    let outcome = match (approve, req.options.first()) {
557        (true, Some(opt)) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
558            opt.option_id.clone(),
559        )),
560        _ => RequestPermissionOutcome::Cancelled,
561    };
562    responder.respond(RequestPermissionResponse::new(outcome))
563}
564
565// ─── Phase 5: handshake + prompt ────────────────────────────────────────────
566
567/// Drive the ACP handshake: `initialize` → `session/new` (with optional
568/// schema-MCP server) → `session/set_config_option` (if a model is requested
569/// and the agent advertises one) → `session/prompt`. Records the resulting
570/// `StopReason` and token usage into the shared state.
571async fn run_handshake_and_prompt(
572    conn: &ConnectionTo<Agent>,
573    cwd: &std::path::Path,
574    schema_file_path: Option<&str>,
575    model: Option<&str>,
576    prompt: &str,
577    acc: &Arc<update_mapper::Accumulator>,
578    stop_holder: &Arc<Mutex<Option<String>>>,
579) -> Result<(), agent_client_protocol::Error> {
580    tracing::debug!("ACP handshake: initialize");
581    conn.send_request(InitializeRequest::new(ProtocolVersion::V1))
582        .block_task()
583        .await?;
584
585    tracing::debug!("ACP handshake: session/new");
586    let ns = session_new(conn, cwd.to_path_buf(), schema_file_path).await?;
587
588    if let Some(model_name) = model {
589        validate_and_set_model(conn, &ns, model_name).await?;
590    }
591
592    tracing::debug!("ACP handshake: session/prompt");
593    let pr = send_prompt(conn, ns.session_id, prompt.to_string()).await?;
594    record_prompt_result(&pr, stop_holder, acc);
595    Ok(())
596}
597
598/// Send `session/new`, attaching the structured-output MCP server when a
599/// schema file path is present.
600async fn session_new(
601    conn: &ConnectionTo<Agent>,
602    cwd: PathBuf,
603    schema_file_path: Option<&str>,
604) -> Result<NewSessionResponse, agent_client_protocol::Error> {
605    let req = NewSessionRequest::new(cwd);
606    let req = match schema_file_path {
607        Some(sf) => {
608            let luft_bin =
609                std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("luft"));
610            let mcp = McpServerStdio::new("luft-structured-output", luft_bin).args(vec![
611                "mcp-structured-output".to_string(),
612                "--schema-file".to_string(),
613                sf.to_string(),
614            ]);
615            req.mcp_servers(vec![McpServer::Stdio(mcp)])
616        }
617        None => req,
618    };
619    conn.send_request(req).block_task().await
620}
621
622/// Validate the requested `model_name` against the agent's advertised
623/// `config_options`. If it's listed (either in the ungrouped or grouped
624/// select options), emit `session/set_config_option`; otherwise log a warning
625/// and fall back to the agent default.
626async fn validate_and_set_model(
627    conn: &ConnectionTo<Agent>,
628    ns: &NewSessionResponse,
629    model_name: &str,
630) -> Result<(), agent_client_protocol::Error> {
631    let config_options = match ns.config_options.as_ref() {
632        Some(opts) => opts,
633        None => {
634            tracing::debug!("ACP: agent does not advertise config_options");
635            return Ok(());
636        }
637    };
638    let model_option = match config_options
639        .iter()
640        .find(|opt| opt.category.as_ref() == Some(&SessionConfigOptionCategory::Model))
641    {
642        Some(o) => o,
643        None => {
644            tracing::debug!("ACP: agent does not support model selection");
645            return Ok(());
646        }
647    };
648    let select = match &model_option.kind {
649        SessionConfigKind::Select(s) => s,
650        _ => {
651            tracing::debug!("ACP: model option is not a Select kind");
652            return Ok(());
653        }
654    };
655    let valid = match &select.options {
656        SessionConfigSelectOptions::Ungrouped(opts) => {
657            opts.iter().any(|o| o.value.0.as_ref() == model_name)
658        }
659        SessionConfigSelectOptions::Grouped(groups) => groups
660            .iter()
661            .any(|g| g.options.iter().any(|o| o.value.0.as_ref() == model_name)),
662        _ => false,
663    };
664    if valid {
665        tracing::debug!(model = %model_name, "ACP: setting session model");
666        let req = SetSessionConfigOptionRequest::new(
667            ns.session_id.clone(),
668            model_option.id.clone(),
669            model_name.to_string(),
670        );
671        conn.send_request(req).block_task().await?;
672    } else {
673        tracing::warn!(
674            model = %model_name,
675            "ACP: requested model not available, using agent default"
676        );
677    }
678    Ok(())
679}
680
681/// Send `session/prompt` and return the agent's response. Does **not** record
682/// the result into shared state — that's [`record_prompt_result`]'s job, so
683/// that the recording logic can be unit-tested without an ACP connection.
684async fn send_prompt(
685    conn: &ConnectionTo<Agent>,
686    session_id: SessionId,
687    prompt: String,
688) -> Result<agent_client_protocol::schema::PromptResponse, agent_client_protocol::Error> {
689    conn.send_request(PromptRequest::new(
690        session_id,
691        vec![ContentBlock::Text(TextContent::new(prompt))],
692    ))
693    .block_task()
694    .await
695}
696
697/// Persist a `PromptResponse` into shared state: the `StopReason` is stored
698/// as a stable string via [`stop_reason_as_str`]; token usage is folded into
699/// the accumulator.
700fn record_prompt_result(
701    pr: &agent_client_protocol::schema::PromptResponse,
702    stop_holder: &Arc<Mutex<Option<String>>>,
703    #[cfg_attr(
704        not(feature = "unstable_end_turn_token_usage"),
705        allow(unused_variables)
706    )]
707    acc: &Arc<update_mapper::Accumulator>,
708) {
709    tracing::debug!(stop_reason = ?pr.stop_reason, "ACP prompt complete");
710    *stop_holder.lock().unwrap() = Some(stop_reason_as_str(&pr.stop_reason));
711    #[cfg(feature = "unstable_end_turn_token_usage")]
712    {
713        if let Some(u) = pr.usage.as_ref() {
714            tracing::debug!(
715                input = u.input_tokens,
716                output = u.output_tokens,
717                total = u.total_tokens,
718                "ACP prompt usage"
719            );
720            *acc.tokens.lock().unwrap() = TokenUsage {
721                input: u.input_tokens,
722                output: u.output_tokens,
723                cache_read: u.cached_read_tokens.unwrap_or(0),
724                cache_write: u.cached_write_tokens.unwrap_or(0),
725            };
726        }
727    }
728}
729
730/// Convert an ACP [`StopReason`] into a stable PascalCase string. This is the
731/// **single source of truth** for the spelling used in [`stop_holder`] and in
732/// the synthesized post-submission timeout value ([`STOP_REASON_END_TURN`]).
733/// Renaming a variant or adding a new one forces an intentional decision
734/// here rather than silently relying on the derived `Debug` format.
735fn stop_reason_as_str(r: &StopReason) -> String {
736    match r {
737        StopReason::EndTurn => STOP_REASON_END_TURN.to_string(),
738        StopReason::MaxTokens => "MaxTokens".to_string(),
739        StopReason::MaxTurnRequests => "MaxTurnRequests".to_string(),
740        StopReason::Refusal => "Refusal".to_string(),
741        StopReason::Cancelled => "Cancelled".to_string(),
742        #[allow(unreachable_patterns)]
743        other => format!("{other:?}"),
744    }
745}
746
747// ─── Phase 6: race / classify / collect ────────────────────────────────────
748
749/// Convert a `WatchdogOutcome` into a `BackendError` (pre-timeouts) or update
750/// the shared `stop_holder` with a synthesized `EndTurn` when the LLM has
751/// already submitted its `structured_output` (post-submission timeout).
752fn handle_watchdog_outcome(
753    res: WatchdogOutcome,
754    child: &mut tokio::process::Child,
755    stop_holder: &Arc<Mutex<Option<String>>>,
756    idle_timeout: Duration,
757) -> Result<(), BackendError> {
758    let _ = child.start_kill();
759    match res {
760        WatchdogOutcome::PreIdleTimeout => {
761            tracing::warn!(
762                idle_timeout_ms = idle_timeout.as_millis() as u64,
763                "ACP session idle timeout (no protocol activity)"
764            );
765            Err(BackendError::Timeout)
766        }
767        WatchdogOutcome::ChannelClosed => {
768            tracing::debug!("ACP activity channel closed");
769            Err(BackendError::Timeout)
770        }
771        WatchdogOutcome::PostSubmissionTimeout => {
772            // The LLM submitted a valid `structured_output` but kept
773            // generating tool calls after that (e.g. `todo_write`). We treat
774            // the captured submission as the session result: synthesize an
775            // `EndTurn` stop_reason and fall through to the normal collection
776            // path. The scheduler's schema-retry loop will then validate the
777            // payload against `task.output_schema`.
778            tracing::info!(
779                post_idle_ms = POST_SUBMISSION_IDLE.as_millis() as u64,
780                "ACP post-submission timeout; treating structured_output as result"
781            );
782            // Single `MutexGuard` (no double-lock): the original code took
783            // the lock twice in a row; we hold it once and only overwrite
784            // when the slot is empty so we don't clobber a stop_reason that
785            // the connection already wrote.
786            let mut guard = stop_holder.lock().unwrap();
787            if guard.is_none() {
788                *guard = Some(STOP_REASON_END_TURN.to_string());
789            }
790            Ok(())
791        }
792    }
793}
794
795/// Classify a connection/protocol error: substring-match for upstream "closed"
796/// indicators → `Protocol("connection closed")`; everything else becomes a
797/// generic `Protocol` error.
798///
799/// NOTE: this classification relies on upstream `Display` / `to_string()`
800/// output. The `agent-client-protocol` crate does not currently expose typed
801/// "connection closed" variants, so we pattern-match the rendered strings.
802/// If that crate ever surfaces a typed variant, replace this with a `match`
803/// on the error type instead of the substrings.
804fn classify_protocol_error(e: agent_client_protocol::Error) -> BackendError {
805    let s = e.to_string();
806    if is_connection_closed(&s) {
807        tracing::warn!("ACP connection closed");
808        BackendError::Protocol("connection closed".into())
809    } else {
810        tracing::error!(error = %s, "ACP protocol error");
811        BackendError::Protocol(s)
812    }
813}
814
815fn is_connection_closed(s: &str) -> bool {
816    // NOTE: see `classify_protocol_error` for why we pattern-match rendered
817    // strings instead of typed error variants. If the ACP crate exposes
818    // typed "closed" indicators, migrate to those.
819    s.contains("receiver dropped")
820        || s.contains("broken pipe")
821        || s.contains("unexpected eof")
822        || s.contains("connection closed")
823}
824
825/// Build the final [`AgentResult`] from the accumulator and stop-reason slot.
826fn collect_session_result(task: &AgentTask, state: &SessionState) -> AgentResult {
827    let stop = state.stop_holder.lock().unwrap().take().unwrap_or_default();
828    let message = std::mem::take(&mut *state.acc.message.lock().unwrap());
829    let tokens = *state.acc.tokens.lock().unwrap();
830    let structured = state.acc.structured_output.lock().unwrap().take();
831    result_collector::collect(task, &stop, message, tokens, structured)
832}
833
834/// Completes after `idle` elapses with **no** signal on `rx`.
835///
836/// Each ACP `session/update` notification sends a `()` to `rx`, resetting
837/// the idle timer. This lets a slow-but-alive agent (e.g. a long tool call
838/// with periodic `ToolCallUpdate` events) run indefinitely while a truly
839/// hung agent (no notifications at all) is killed after `idle`.
840///
841/// When `submit_signal.notify_one()` is called, the watchdog transitions
842/// into a **post-submission** state: it switches to a short `post_idle`
843/// timer that is **not** reset by further activity on `activity_rx`. This
844/// bounds the wait once the LLM has submitted a valid `structured_output`
845/// but keeps producing tool calls (e.g. `todo_write`) — the exact race
846/// that produced the `story-UpdateDialog` and `story-MessageListSubmodule`
847/// failures documented in
848/// `docs/issues/opengui-stories-2026-07-06-stuck-run.md`.
849///
850/// `submit_signal` is a `tokio::sync::Notify` (not an mpsc) so the
851/// `notified()` future integrates cleanly with `select!` and resolves
852/// immediately if `notify_one()` was already called. `Notify` has no
853/// "closed" state: if the handler is dropped without notifying, the
854/// `notified()` future blocks indefinitely and the `pre_idle` timer is
855/// the backstop.
856async fn idle_watchdog(
857    pre_idle: Duration,
858    post_idle: Duration,
859    activity_rx: &mut tokio::sync::mpsc::UnboundedReceiver<()>,
860    submit_signal: Arc<tokio::sync::Notify>,
861) -> WatchdogOutcome {
862    let mut submitted = false;
863    loop {
864        if submitted {
865            // Drain any trailing notifications: they DO NOT reset the timer.
866            // The LLM has already submitted; we are giving it a fixed grace
867            // period to stop, after which we kill the session.
868            //
869            // We deliberately do NOT wait on `submit_signal` or
870            // `activity_rx` here: the submission is one-shot and any
871            // further activity is irrelevant to whether the captured
872            // `structured_output` is the result.
873            while activity_rx.try_recv().is_ok() {}
874            tokio::time::sleep(post_idle).await;
875            return WatchdogOutcome::PostSubmissionTimeout;
876        }
877        tokio::select! {
878            biased;
879            _ = submit_signal.notified() => {
880                submitted = true;
881                tracing::debug!(
882                    post_idle_ms = post_idle.as_millis() as u64,
883                    "ACP watchdog entered post-submission mode"
884                );
885            }
886            msg = activity_rx.recv() => match msg {
887                Some(()) => { while activity_rx.try_recv().is_ok() {} }
888                None => return WatchdogOutcome::ChannelClosed,
889            },
890            _ = tokio::time::sleep(pre_idle) => {
891                return WatchdogOutcome::PreIdleTimeout;
892            }
893        }
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    // ── idle_watchdog ──────────────────────────────────────────────
902    //
903    // The watchdog has a two-state machine: pre-submission (timer resets
904    // on activity) and post-submission (fixed short timer, no reset).
905    // These tests exercise both paths and the transition between them.
906
907    #[tokio::test]
908    async fn idle_watchdog_fires_after_idle_period() {
909        let (_atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
910        let submit = Arc::new(tokio::sync::Notify::new());
911        let r = tokio::time::timeout(
912            Duration::from_millis(500),
913            idle_watchdog(
914                Duration::from_millis(50),
915                Duration::from_millis(50),
916                &mut arx,
917                submit,
918            ),
919        )
920        .await;
921        let outcome = r.expect("should fire after idle period");
922        assert_eq!(outcome, WatchdogOutcome::PreIdleTimeout);
923    }
924
925    #[tokio::test]
926    async fn idle_watchdog_does_not_fire_with_activity() {
927        let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
928        let submit = Arc::new(tokio::sync::Notify::new());
929        tokio::spawn(async move {
930            for _ in 0..5 {
931                tokio::time::sleep(Duration::from_millis(20)).await;
932                let _ = atx.send(());
933            }
934        });
935        let r = tokio::time::timeout(
936            Duration::from_millis(80),
937            idle_watchdog(
938                Duration::from_millis(50),
939                Duration::from_millis(50),
940                &mut arx,
941                submit,
942            ),
943        )
944        .await;
945        assert!(
946            r.is_err(),
947            "should not fire while activity is within idle window"
948        );
949    }
950
951    #[tokio::test]
952    async fn idle_watchdog_fires_after_activity_stops() {
953        let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
954        let submit = Arc::new(tokio::sync::Notify::new());
955        let _ = atx.send(());
956        drop(atx);
957        let r = tokio::time::timeout(
958            Duration::from_millis(30),
959            idle_watchdog(
960                Duration::from_millis(80),
961                Duration::from_millis(80),
962                &mut arx,
963                submit,
964            ),
965        )
966        .await;
967        let outcome = r.expect("should return immediately when channel closes");
968        assert_eq!(outcome, WatchdogOutcome::ChannelClosed);
969    }
970
971    // ── Post-submission mode (Fix M1) ───────────────────────────────
972    //
973    // The pre-submission watchdog keeps the session alive while the LLM
974    // is actively emitting notifications. Once `submit_signal.notify_one()`
975    // is called, the watchdog must switch to a short, non-resetting
976    // post-idle timer so a chatty post-submission agent (one that keeps
977    // calling `todo_write` after submitting `structured_output`) cannot
978    // hang the session. This is the race that produced
979    // `story-UpdateDialog` / `story-MessageListSubmodule` failures in
980    // `docs/issues/opengui-stories-2026-07-06-stuck-run.md`.
981
982    #[tokio::test]
983    async fn idle_watchdog_enters_post_mode_after_submit_signal() {
984        let (_atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
985        let submit = Arc::new(tokio::sync::Notify::new());
986        let submit_h = submit.clone();
987        tokio::spawn(async move {
988            tokio::time::sleep(Duration::from_millis(20)).await;
989            submit_h.notify_one();
990        });
991        let r = tokio::time::timeout(
992            Duration::from_millis(500),
993            idle_watchdog(
994                Duration::from_secs(60),   // long pre-idle (should never fire)
995                Duration::from_millis(50), // short post-idle
996                &mut arx,
997                submit,
998            ),
999        )
1000        .await;
1001        let outcome = r.expect("watchdog should return after post_idle");
1002        assert_eq!(outcome, WatchdogOutcome::PostSubmissionTimeout);
1003    }
1004
1005    #[tokio::test]
1006    async fn idle_watchdog_post_mode_is_not_reset_by_activity() {
1007        // KEY INVARIANT: once the LLM has submitted, additional activity
1008        // ticks must NOT extend the wait. This is what prevents the
1009        // 2.5-minute hang observed in the opencode run.
1010        let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
1011        let submit = Arc::new(tokio::sync::Notify::new());
1012        submit.notify_one();
1013        tokio::spawn(async move {
1014            for _ in 0..20 {
1015                tokio::time::sleep(Duration::from_millis(20)).await;
1016                let _ = atx.send(());
1017            }
1018        });
1019        let start = std::time::Instant::now();
1020        let r = tokio::time::timeout(
1021            Duration::from_millis(500),
1022            idle_watchdog(
1023                Duration::from_secs(60),
1024                Duration::from_millis(80),
1025                &mut arx,
1026                submit,
1027            ),
1028        )
1029        .await;
1030        let outcome = r.expect("watchdog should return after post_idle");
1031        let elapsed = start.elapsed();
1032        assert_eq!(outcome, WatchdogOutcome::PostSubmissionTimeout);
1033        // The post-idle timer must dominate: even with 20 activity ticks
1034        // (totalling ~400 ms), the watchdog should fire at ~80 ms
1035        // post-submit. We allow generous slack for CI scheduling jitter.
1036        assert!(
1037            elapsed < Duration::from_millis(300),
1038            "post-mode timer was reset by activity: elapsed={elapsed:?}"
1039        );
1040    }
1041
1042    #[tokio::test]
1043    async fn idle_watchdog_pre_mode_resets_on_activity() {
1044        // Sanity: pre-submission behavior is preserved (regression guard).
1045        let (atx, mut arx) = tokio::sync::mpsc::unbounded_channel::<()>();
1046        let submit = Arc::new(tokio::sync::Notify::new());
1047        tokio::spawn(async move {
1048            for _ in 0..10 {
1049                tokio::time::sleep(Duration::from_millis(30)).await;
1050                let _ = atx.send(());
1051            }
1052        });
1053        let r = tokio::time::timeout(
1054            Duration::from_millis(200),
1055            idle_watchdog(
1056                Duration::from_millis(60),
1057                Duration::from_millis(60),
1058                &mut arx,
1059                submit,
1060            ),
1061        )
1062        .await;
1063        assert!(
1064            r.is_err(),
1065            "pre-mode should not fire while activity keeps resetting timer"
1066        );
1067    }
1068
1069    // ── stop_reason_as_str (F5 / F6) ────────────────────────────────
1070    //
1071    // The persisted stop_reason string is consumed by
1072    // `result_collector::status_from_stop_reason`, which substring-matches
1073    // on the PascalCase Debug-derived spelling. We pin the spelling here
1074    // so that any future change to the helper is intentional.
1075
1076    #[test]
1077    fn stop_reason_as_str_end_turn_matches_constant() {
1078        assert_eq!(
1079            stop_reason_as_str(&StopReason::EndTurn),
1080            STOP_REASON_END_TURN
1081        );
1082        assert_eq!(stop_reason_as_str(&StopReason::EndTurn), "EndTurn");
1083    }
1084
1085    #[test]
1086    fn stop_reason_as_str_cancelled_contains_cancel() {
1087        assert_eq!(stop_reason_as_str(&StopReason::Cancelled), "Cancelled");
1088    }
1089
1090    #[test]
1091    fn stop_reason_as_str_other_variants_stable() {
1092        assert_eq!(stop_reason_as_str(&StopReason::MaxTokens), "MaxTokens");
1093        assert_eq!(
1094            stop_reason_as_str(&StopReason::MaxTurnRequests),
1095            "MaxTurnRequests"
1096        );
1097        assert_eq!(stop_reason_as_str(&StopReason::Refusal), "Refusal");
1098    }
1099
1100    // ── handle_watchdog_outcome (F7) ────────────────────────────────
1101    //
1102    // Post-submission timeout synthesizes an EndTurn stop_reason via a
1103    // single MutexGuard (no double-lock) and only when the slot is empty.
1104
1105    #[tokio::test]
1106    async fn handle_watchdog_post_submission_synthesizes_end_turn() {
1107        let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1108        // We don't have a real child here; use a dummy. We only care that
1109        // `start_kill` doesn't panic on a process we never spawned.
1110        let mut child = match tokio::process::Command::new("cmd")
1111            .arg("/C")
1112            .arg("exit 0")
1113            .stdin(Stdio::null())
1114            .stdout(Stdio::null())
1115            .stderr(Stdio::null())
1116            .kill_on_drop(true)
1117            .spawn()
1118        {
1119            Ok(c) => c,
1120            Err(_) => return, // non-Windows CI: skip
1121        };
1122        let r = handle_watchdog_outcome(
1123            WatchdogOutcome::PostSubmissionTimeout,
1124            &mut child,
1125            &stop,
1126            Duration::from_secs(300),
1127        );
1128        assert!(
1129            r.is_ok(),
1130            "post-submission outcome should fall through to collect"
1131        );
1132        assert_eq!(stop.lock().unwrap().as_deref(), Some("EndTurn"));
1133    }
1134
1135    #[tokio::test]
1136    async fn handle_watchdog_post_submission_preserves_existing_stop() {
1137        let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(Some("Cancelled".into())));
1138        let mut child = match tokio::process::Command::new("cmd")
1139            .arg("/C")
1140            .arg("exit 0")
1141            .stdin(Stdio::null())
1142            .stdout(Stdio::null())
1143            .stderr(Stdio::null())
1144            .kill_on_drop(true)
1145            .spawn()
1146        {
1147            Ok(c) => c,
1148            Err(_) => return,
1149        };
1150        let r = handle_watchdog_outcome(
1151            WatchdogOutcome::PostSubmissionTimeout,
1152            &mut child,
1153            &stop,
1154            Duration::from_secs(300),
1155        );
1156        assert!(r.is_ok());
1157        // Must not clobber a stop reason the connection already wrote.
1158        assert_eq!(stop.lock().unwrap().as_deref(), Some("Cancelled"));
1159    }
1160
1161    #[tokio::test]
1162    async fn handle_watchdog_pre_idle_returns_timeout_error() {
1163        let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1164        let mut child = match tokio::process::Command::new("cmd")
1165            .arg("/C")
1166            .arg("exit 0")
1167            .stdin(Stdio::null())
1168            .stdout(Stdio::null())
1169            .stderr(Stdio::null())
1170            .kill_on_drop(true)
1171            .spawn()
1172        {
1173            Ok(c) => c,
1174            Err(_) => return,
1175        };
1176        let r = handle_watchdog_outcome(
1177            WatchdogOutcome::PreIdleTimeout,
1178            &mut child,
1179            &stop,
1180            Duration::from_secs(1),
1181        );
1182        assert!(matches!(r, Err(BackendError::Timeout)));
1183    }
1184
1185    #[tokio::test]
1186    async fn handle_watchdog_channel_closed_returns_timeout_error() {
1187        let stop: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1188        let mut child = match tokio::process::Command::new("cmd")
1189            .arg("/C")
1190            .arg("exit 0")
1191            .stdin(Stdio::null())
1192            .stdout(Stdio::null())
1193            .stderr(Stdio::null())
1194            .kill_on_drop(true)
1195            .spawn()
1196        {
1197            Ok(c) => c,
1198            Err(_) => return,
1199        };
1200        let r = handle_watchdog_outcome(
1201            WatchdogOutcome::ChannelClosed,
1202            &mut child,
1203            &stop,
1204            Duration::from_secs(1),
1205        );
1206        assert!(matches!(r, Err(BackendError::Timeout)));
1207    }
1208
1209    // ── is_connection_closed (F4) ───────────────────────────────────
1210
1211    #[test]
1212    fn is_connection_closed_matches_documented_substrings() {
1213        assert!(is_connection_closed("receiver dropped"));
1214        assert!(is_connection_closed("broken pipe"));
1215        assert!(is_connection_closed("unexpected eof"));
1216        assert!(is_connection_closed("connection closed"));
1217        assert!(is_connection_closed(
1218            "io error: broken pipe writing to stdin"
1219        ));
1220        assert!(!is_connection_closed("unknown protocol method"));
1221        assert!(!is_connection_closed(""));
1222    }
1223}