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