Skip to main content

winx_code_agent/tools/
bash_command.rs

1//! `BashCommand` facade and action dispatcher.
2//!
3//! Execution, output/status rendering, interactive input, and TUI driving live
4//! in focused submodules under `tools/bash_command/`.
5
6mod execution;
7mod interaction;
8mod output;
9mod tui;
10mod workflow;
11
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16use tokio::sync::Mutex;
17use tracing::{debug, error, info};
18
19pub use super::background_shell::{BackgroundShellManager, ExitedShellInfo};
20use crate::errors::{Result, WinxError};
21use crate::runtime::{
22    lock_session_store, BashCommandRuntimeResult, EmbeddedShellRuntime, ShellActionOptions,
23    ShellExecutionToken, ShellRuntime, ShellTarget,
24};
25use crate::state::bash_state::BashState;
26use crate::state::live_terminal::ScreenUpdate;
27use crate::state::pty::PtyShell;
28use crate::types::{normalize_thread_id, BashCommand, BashCommandAction};
29
30use execution::{execute_command, CommandExecutionContext};
31use interaction::{execute_send_ascii, execute_send_specials, execute_send_text};
32use output::{execute_status_check, finalize_tombstone};
33use tui::{execute_screen, execute_wait_for_turn};
34
35type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
36
37/// Authoritative process state produced by the shell runtime. It is transported
38/// separately from human-readable terminal output, so command text cannot spoof
39/// orchestration state by printing Winx-looking markers.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum BashProcessStatus {
43    Running,
44    Exited,
45}
46
47/// Machine-readable state of one `BashCommand` action.
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49pub struct BashCommandState {
50    pub process_status: BashProcessStatus,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub background_id: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub running_for_seconds: Option<u64>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub exit_code: Option<i32>,
57    pub cwd: PathBuf,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub turn_state: Option<crate::state::turn::TurnState>,
60}
61
62impl BashCommandState {
63    pub const fn is_running(&self) -> bool {
64        matches!(self.process_status, BashProcessStatus::Running)
65    }
66}
67
68/// Human-readable output plus runtime-owned state. Only `state` drives MCP
69/// orchestration; `output` is presentation and may contain arbitrary child text.
70#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct BashCommandResult {
72    pub output: String,
73    pub state: BashCommandState,
74}
75
76pub(crate) fn runtime_rendered(
77    mut body: String,
78    legacy_status: &str,
79    state: BashCommandState,
80    compact_requested: bool,
81    command_generation: Option<u64>,
82    output_truncated: bool,
83) -> BashCommandRuntimeResult {
84    let compact_output = if compact_requested {
85        Some(std::mem::take(&mut body))
86    } else {
87        body.push_str(legacy_status);
88        None
89    };
90    BashCommandRuntimeResult {
91        result: BashCommandResult { output: body, state },
92        compact_output,
93        command_generation,
94        execution_token: None,
95        action_context: None,
96        generation_bound_actions: true,
97        dropped_output_file: None,
98        output_truncated,
99        workflow: None,
100    }
101}
102
103/// Per-adapter delivery state used by daemon guardians. Embedded callers keep
104/// using the cursor stored directly on `PtyShell`, preserving legacy behavior.
105#[derive(Debug, Default)]
106pub(crate) struct ShellDeliveryCursor {
107    generation: Option<u64>,
108    delivered_output: String,
109    last_returned_hash: Option<u64>,
110    screen_snapshot: Option<Vec<String>>,
111}
112
113impl ShellDeliveryCursor {
114    fn sync_generation(&mut self, generation: u64) {
115        if self.generation != Some(generation) {
116            self.generation = Some(generation);
117            self.delivered_output.clear();
118            self.last_returned_hash = None;
119            self.screen_snapshot = None;
120        }
121    }
122
123    fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
124        let update = match self.screen_snapshot.as_ref() {
125            Some(previous) => {
126                let rows = previous.len().max(current.len());
127                let changed = (0..rows)
128                    .filter_map(|index| {
129                        let before = previous.get(index).map_or("", String::as_str);
130                        let after = current.get(index).map_or("", String::as_str);
131                        (before != after).then(|| (index + 1, after.to_string()))
132                    })
133                    .collect::<Vec<_>>();
134                if changed.is_empty() {
135                    ScreenUpdate::Unchanged
136                } else if changed.len() <= threshold {
137                    ScreenUpdate::Diff(changed)
138                } else {
139                    ScreenUpdate::Full(current.clone())
140                }
141            }
142            None => ScreenUpdate::Full(current.clone()),
143        };
144        self.screen_snapshot = Some(current);
145        update
146    }
147}
148
149pub(crate) fn main_shell(bash_state: &BashState) -> SharedPtyShell {
150    let mut store = lock_session_store();
151    store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
152    store
153        .resolve(&bash_state.current_thread_id, &ShellTarget::Main)
154        .unwrap_or_else(|| bash_state.pty_shell.clone())
155}
156
157// Default block window when the client omits `wait_for_seconds`. Usage logs
158// showed remote clients omit it and then burn one full LLM round-trip per 5 s
159// re-check on long commands (runs of up to 18 consecutive polls); a larger
160// window resolves most commands in a single call while staying far below the
161// 120 s HTTP request timeout.
162const DEFAULT_TIMEOUT: f64 = 15.0;
163fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
164    wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
165}
166
167/// Guardian foreground preflight. The caller owns the session operation
168/// writer, so this never races token validation and runs before delivery cursor
169/// or foreground-command gates are acquired.
170pub(crate) async fn guardian_foreground_shell_needs_reset(
171    bash_state: &mut BashState,
172    options: &ShellActionOptions,
173) -> anyhow::Result<bool> {
174    let shell = main_shell(bash_state);
175    let (missing, running) = {
176        let guard = shell.lock().await;
177        (guard.is_none(), guard.as_ref().is_some_and(|shell| shell.command_running))
178    };
179    if running {
180        return Ok(false);
181    }
182    if missing {
183        bash_state.init_pty_shell().await?;
184        return Ok(false);
185    }
186    let cleared = output::clear_to_run_async(&shell, DEFAULT_TIMEOUT).await;
187    #[cfg(test)]
188    let cleared = cleared && !options.force_clear_to_run_failure;
189    #[cfg(not(test))]
190    let _ = options;
191    Ok(!cleared)
192}
193
194fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
195    let mut start = 0;
196    while start < text.len() {
197        let mut end = (start + chunk_size).min(text.len());
198        while !text.is_char_boundary(end) {
199            end -= 1;
200        }
201        if end == start {
202            end =
203                text[start..].char_indices().nth(1).map_or(text.len(), |(index, _)| start + index);
204        }
205        shell.send_text(&text[start..end]).map_err(|error| {
206            WinxError::CommandExecutionError(format!("Failed to write PTY input: {error}"))
207        })?;
208        start = end;
209    }
210    Ok(())
211}
212
213/// Handle a `BashCommand` using the embedded runtime.
214pub async fn handle_tool_call(
215    bash_state: &Arc<Mutex<Option<BashState>>>,
216    command: BashCommand,
217) -> Result<String> {
218    handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state, command).await
219}
220
221/// Backwards-compatible text-only API used by direct library callers.
222pub async fn handle_tool_call_with_runtime(
223    runtime: &dyn ShellRuntime,
224    bash_state: &Arc<Mutex<Option<BashState>>>,
225    command: BashCommand,
226) -> Result<String> {
227    Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state, command).await?.output)
228}
229
230/// Execute a `BashCommand` while preserving the runtime-owned typed state.
231pub async fn handle_tool_call_with_runtime_detailed(
232    runtime: &dyn ShellRuntime,
233    bash_state: &Arc<Mutex<Option<BashState>>>,
234    command: BashCommand,
235) -> Result<BashCommandResult> {
236    runtime.run_action(bash_state, command).await
237}
238
239pub(crate) async fn handle_embedded_tool_call(
240    bash_state: &Arc<Mutex<Option<BashState>>>,
241    command: BashCommand,
242) -> Result<BashCommandResult> {
243    Ok(handle_embedded_tool_call_inner(
244        bash_state,
245        command,
246        None,
247        ShellActionOptions::default(),
248        false,
249    )
250    .await?
251    .result)
252}
253
254pub(crate) async fn handle_embedded_tool_call_detailed(
255    bash_state: &Arc<Mutex<Option<BashState>>>,
256    command: BashCommand,
257    options: ShellActionOptions,
258) -> Result<BashCommandRuntimeResult> {
259    handle_embedded_tool_call_inner(bash_state, command, None, options, false).await
260}
261
262pub(crate) async fn handle_embedded_tool_call_with_cursor_detailed(
263    bash_state: &Arc<Mutex<Option<BashState>>>,
264    command: BashCommand,
265    delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
266    options: ShellActionOptions,
267    foreground_preflight_complete: bool,
268) -> Result<BashCommandRuntimeResult> {
269    let mut delivery_cursor = delivery_cursor.lock().await;
270    handle_embedded_tool_call_inner(
271        bash_state,
272        command,
273        Some(&mut delivery_cursor),
274        options,
275        foreground_preflight_complete,
276    )
277    .await
278}
279
280async fn capture_embedded_state(
281    bash_state: &Arc<Mutex<Option<BashState>>>,
282    thread_id: &str,
283) -> Result<(BashState, tokio::sync::OwnedRwLockReadGuard<()>)> {
284    let guard = bash_state.lock().await;
285    let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
286    let operation_barrier = lock_session_store().operation_barrier(thread_id);
287    let operation_guard = operation_barrier.read_owned().await;
288    Ok((state.clone(), operation_guard))
289}
290
291#[tracing::instrument(level = "info", skip(bash_state, command, delivery_cursor))]
292async fn handle_embedded_tool_call_inner(
293    bash_state: &Arc<Mutex<Option<BashState>>>,
294    command: BashCommand,
295    delivery_cursor: Option<&mut ShellDeliveryCursor>,
296    options: ShellActionOptions,
297    foreground_preflight_complete: bool,
298) -> Result<BashCommandRuntimeResult> {
299    let action_kind = match &command.action_json {
300        BashCommandAction::Command { .. } => "command",
301        BashCommandAction::StatusCheck { .. } => "status_check",
302        BashCommandAction::SendText { .. } => "send_text",
303        BashCommandAction::SendSpecials { .. } => "send_specials",
304        BashCommandAction::SendAscii { .. } => "send_ascii",
305        BashCommandAction::Screen { .. } => "screen",
306        BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
307        BashCommandAction::Inspect { .. } => "inspect",
308        BashCommandAction::Prompt { .. } => "prompt",
309    };
310    info!(thread_id = %command.thread_id, action = action_kind, "BashCommand tool called");
311
312    let thread_id = normalize_thread_id(&command.thread_id);
313    if thread_id.is_empty() {
314        error!("Empty thread_id provided in BashCommand");
315        return Err(WinxError::ThreadIdMismatch(
316            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID."
317                .to_string(),
318        ));
319    }
320
321    let mut local_state = {
322        let guard = bash_state.lock().await;
323        let Some(state) = &*guard else {
324            error!("BashState not initialized");
325            return Err(WinxError::BashStateNotInitialized);
326        };
327        state.clone()
328    };
329
330    if thread_id != local_state.current_thread_id {
331        let loaded = local_state.load_state_from_disk(&thread_id).map_err(|error| {
332            WinxError::CommandExecutionError(format!(
333                "Failed to load saved bash state for thread_id `{thread_id}`: {error}"
334            ))
335        })?;
336        if !loaded {
337            return Err(WinxError::ThreadIdMismatch(format!(
338                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
339            )));
340        }
341        if let Some(state) = bash_state.lock().await.as_mut() {
342            *state = local_state.clone();
343        }
344    }
345
346    // BashState -> session barrier is the same order used by
347    // configure_session. Keep BashState locked until the owned read guard is
348    // acquired so reset cannot replace the incarnation after capture but
349    // before token validation/action execution.
350    let (captured_state, operation_guard) = capture_embedded_state(bash_state, &thread_id).await?;
351    local_state = captured_state;
352
353    if let Some(expected) = options.expected_execution.as_ref() {
354        if expected.guardian_epoch == "embedded" {
355            let current_epoch = local_state
356                .pty_shell
357                .lock()
358                .await
359                .as_ref()
360                .map(|shell| format!("{:016x}", shell.incarnation()));
361            if Some(expected.session_epoch.as_str()) != current_epoch.as_deref() {
362                return Err(WinxError::InvalidInput(
363                    "execution token belongs to a previous embedded shell incarnation".to_string(),
364                ));
365            }
366        }
367    }
368
369    lock_session_store().bind_main(&local_state.current_thread_id, &local_state.pty_shell);
370    let timeout_secs = effective_wait_for_seconds(command.wait_for_seconds);
371    let mut result = execute_bash_action(
372        &mut local_state,
373        &command.action_json,
374        timeout_secs,
375        delivery_cursor,
376        options,
377        foreground_preflight_complete,
378    )
379    .await;
380
381    let completed_session_epoch = local_state.pty_shell.lock().await.as_ref().map_or_else(
382        || "uninitialized".to_string(),
383        |shell| format!("{:016x}", shell.incarnation()),
384    );
385    if let Ok(outcome) = &mut result {
386        outcome.action_context = Some(crate::runtime::ShellActionContext {
387            thread_id: local_state.current_thread_id.clone(),
388            workspace_root: local_state.workspace_root.clone(),
389        });
390        if let Some(generation) = outcome.command_generation {
391            outcome.execution_token = Some(ShellExecutionToken {
392                guardian_epoch: "embedded".to_string(),
393                session_epoch: completed_session_epoch.clone(),
394                generation,
395            });
396        }
397        if let BashCommandAction::Command { ref command, .. } = command.action_json {
398            strip_submitted_command_echo(outcome, command);
399        }
400    }
401
402    drop(operation_guard);
403    sync_embedded_cwd_if_current(bash_state, &local_state, &completed_session_epoch).await;
404
405    result
406}
407
408fn strip_submitted_command_echo(outcome: &mut BashCommandRuntimeResult, command: &str) {
409    let command = command.trim();
410    if outcome.result.output.starts_with(command) {
411        outcome.result.output = outcome.result.output[command.len()..].to_string();
412    }
413    if outcome.compact_output.as_ref().is_some_and(|output| output.starts_with(command)) {
414        let output = outcome.compact_output.take().unwrap_or_default();
415        outcome.compact_output = Some(output[command.len()..].to_string());
416    }
417}
418
419async fn sync_embedded_cwd_if_current(
420    bash_state: &Arc<Mutex<Option<BashState>>>,
421    local_state: &BashState,
422    completed_session_epoch: &str,
423) {
424    // configure_session callers own BashState before taking the operation
425    // barrier. Acquire BashState only after releasing the read side above and
426    // never let a completed old incarnation overwrite reset's cwd.
427    if let Some(state) = bash_state.lock().await.as_mut() {
428        let current_session_epoch = state.pty_shell.lock().await.as_ref().map_or_else(
429            || "uninitialized".to_string(),
430            |shell| format!("{:016x}", shell.incarnation()),
431        );
432        if current_session_epoch == completed_session_epoch {
433            state.cwd.clone_from(&local_state.cwd);
434        }
435    }
436}
437
438/// Persist accepted launch evidence while the foreground command gate is held.
439/// Backend acceptance and later in-TUI conversation switches remain unverified.
440fn record_foreground_agent_launch(
441    bash_state: &BashState,
442    command: &str,
443    launch_cwd: &std::path::Path,
444) {
445    use crate::state::agent_resume;
446    let thread_id = &bash_state.current_thread_id;
447    match agent_resume::detect_agent_launch(command) {
448        Some(agent) => {
449            let record = agent_resume::AgentSessionRecord::from_launch(
450                agent,
451                command,
452                launch_cwd,
453                &bash_state.workspace_root,
454                thread_id,
455                // The final guardian execution token is assigned outside this boundary.
456                None,
457            );
458            if let Err(error) = agent_resume::save_agent_session(thread_id, &record) {
459                debug!(%error, agent, "failed to persist agent-session record");
460                if let Err(clear_error) = agent_resume::clear_agent_session(thread_id) {
461                    debug!(%clear_error, agent, "failed to invalidate the previous agent-session record");
462                }
463            }
464        }
465        None => {
466            if let Err(error) = agent_resume::clear_agent_session(thread_id) {
467                debug!(%error, "failed to clear agent-session record");
468            }
469        }
470    }
471}
472
473#[allow(clippy::too_many_lines)]
474async fn execute_bash_action(
475    bash_state: &mut BashState,
476    action: &BashCommandAction,
477    timeout_secs: f64,
478    delivery_cursor: Option<&mut ShellDeliveryCursor>,
479    options: ShellActionOptions,
480    foreground_preflight_complete: bool,
481) -> Result<BashCommandRuntimeResult> {
482    let mut is_background = false;
483    let mut background_id: Option<String> = None;
484
485    let background_shell = match action {
486        BashCommandAction::Command { .. } => None,
487        BashCommandAction::StatusCheck { bg_command_id, .. }
488        | BashCommandAction::SendText { bg_command_id, .. }
489        | BashCommandAction::SendSpecials { bg_command_id, .. }
490        | BashCommandAction::SendAscii { bg_command_id, .. }
491        | BashCommandAction::Screen { bg_command_id, .. }
492        | BashCommandAction::WaitForTurn { bg_command_id, .. }
493        | BashCommandAction::Inspect { bg_command_id, .. }
494        | BashCommandAction::Prompt { bg_command_id, .. } => {
495            if let Some(id) = bg_command_id {
496                let mut manager = lock_session_store();
497                manager.prune_finished_shells();
498                if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
499                    is_background = true;
500                    background_id = Some(id.clone());
501                    Some(shell)
502                } else if let Some(tombstone) =
503                    manager.peek_tombstone(&bash_state.current_thread_id, id)
504                {
505                    workflow::validate_tombstone(&tombstone, &options)?;
506                    drop(manager);
507                    return finalize_tombstone(
508                        bash_state,
509                        id,
510                        tombstone,
511                        action,
512                        options.compact_output,
513                    );
514                } else {
515                    let error = format!(
516                        "No shell found running with command id {}.\n{}",
517                        id,
518                        manager.get_running_info(&bash_state.current_thread_id)
519                    );
520                    return Err(WinxError::InvalidInput(error));
521                }
522            } else {
523                None
524            }
525        }
526    };
527
528    match action {
529        BashCommandAction::Command { command, is_background, allow_multi } => {
530            execute_command(
531                bash_state,
532                command,
533                *is_background,
534                *allow_multi,
535                timeout_secs,
536                delivery_cursor,
537                CommandExecutionContext { options: &options, foreground_preflight_complete },
538            )
539            .await
540        }
541        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
542            execute_status_check(
543                bash_state,
544                background_shell,
545                is_background,
546                background_id.as_deref(),
547                timeout_secs,
548                *scrollback_lines,
549                *verbose,
550                delivery_cursor,
551                options,
552            )
553            .await
554        }
555        BashCommandAction::SendText { send_text, submit, .. } => {
556            execute_send_text(
557                bash_state,
558                send_text,
559                *submit,
560                background_shell,
561                is_background,
562                background_id.as_deref(),
563                timeout_secs,
564                delivery_cursor,
565                options.compact_output,
566            )
567            .await
568        }
569        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
570            execute_send_specials(
571                bash_state,
572                send_specials,
573                *submit,
574                background_shell,
575                is_background,
576                background_id.as_deref(),
577                timeout_secs,
578                delivery_cursor,
579                options.compact_output,
580            )
581            .await
582        }
583        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
584            execute_send_ascii(
585                bash_state,
586                send_ascii,
587                *submit,
588                background_shell,
589                is_background,
590                background_id.as_deref(),
591                timeout_secs,
592                delivery_cursor,
593                options.compact_output,
594            )
595            .await
596        }
597        BashCommandAction::Screen { lines, diff, .. } => {
598            execute_screen(
599                bash_state,
600                background_shell,
601                is_background,
602                background_id.as_deref(),
603                *lines,
604                *diff,
605                delivery_cursor,
606                options.compact_output,
607            )
608            .await
609        }
610        BashCommandAction::WaitForTurn {
611            recognizer,
612            quiet_ms,
613            timeout_seconds,
614            lines,
615            wait_through_busy,
616            ..
617        } => {
618            execute_wait_for_turn(
619                bash_state,
620                background_shell,
621                is_background,
622                background_id.as_deref(),
623                recognizer.as_deref(),
624                *quiet_ms,
625                *timeout_seconds,
626                *lines,
627                *wait_through_busy,
628                options.compact_output,
629            )
630            .await
631        }
632        BashCommandAction::Inspect { recognizer, include_output, .. } => {
633            workflow::execute_inspect(
634                bash_state,
635                background_shell,
636                background_id.as_deref(),
637                recognizer.as_deref(),
638                include_output.unwrap_or(true),
639                &options,
640            )
641            .await
642        }
643        BashCommandAction::Prompt { prompt_id, text, recognizer, timeout_seconds, .. } => {
644            workflow::execute_prompt(
645                bash_state,
646                background_shell,
647                background_id.as_deref(),
648                prompt_id,
649                text.as_deref(),
650                recognizer.as_deref(),
651                *timeout_seconds,
652                &options,
653            )
654            .await
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::{
662        effective_wait_for_seconds, runtime_rendered, BashCommandState, BashProcessStatus,
663    };
664    use anyhow::Context;
665
666    fn exited_state() -> BashCommandState {
667        BashCommandState {
668            process_status: BashProcessStatus::Exited,
669            background_id: None,
670            running_for_seconds: None,
671            exit_code: Some(0),
672            cwd: "/workspace".into(),
673            turn_state: None,
674        }
675    }
676
677    #[test]
678    fn legacy_rendering_does_not_build_a_compact_payload_without_negotiation() {
679        let result = runtime_rendered(
680            "child output".to_string(),
681            "\nstatus = process exited",
682            exited_state(),
683            false,
684            Some(1),
685            false,
686        );
687        assert_eq!(result.result.output, "child output\nstatus = process exited");
688        assert_eq!(result.compact_output, None);
689    }
690
691    #[test]
692    fn compact_rendering_is_built_directly_from_runtime_body() {
693        let result = runtime_rendered(
694            "child output".to_string(),
695            "\nstatus = process exited",
696            exited_state(),
697            true,
698            Some(1),
699            false,
700        );
701        assert_eq!(result.compact_output.as_deref(), Some("child output"));
702        assert!(result.result.output.is_empty(), "expected empty result.result.output");
703    }
704
705    #[test]
706    fn requested_wait_is_not_silently_capped() {
707        assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
708        assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
709    }
710
711    #[test]
712    fn omitted_wait_uses_the_larger_default_window() {
713        assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
714    }
715
716    struct SelectorRecordCleanup(String);
717
718    impl Drop for SelectorRecordCleanup {
719        fn drop(&mut self) {
720            let _ = crate::state::agent_resume::clear_agent_session(&self.0);
721        }
722    }
723
724    #[cfg(unix)]
725    async fn selector_runtime_command(
726        state: &std::sync::Arc<tokio::sync::Mutex<Option<crate::state::bash_state::BashState>>>,
727        thread_id: &str,
728        command: &str,
729        background: bool,
730    ) -> crate::errors::Result<crate::runtime::BashCommandRuntimeResult> {
731        super::handle_embedded_tool_call_detailed(
732            state,
733            crate::types::BashCommand {
734                thread_id: thread_id.to_string(),
735                wait_for_seconds: Some(0.25),
736                action_json: crate::types::BashCommandAction::Command {
737                    command: command.to_string(),
738                    is_background: background,
739                    allow_multi: false,
740                },
741            },
742            crate::runtime::ShellActionOptions::default(),
743        )
744        .await
745    }
746
747    #[cfg(unix)]
748    #[tokio::test]
749    async fn selector_recovery_new_conversation_launch_persists_its_target_across_reload(
750    ) -> anyhow::Result<()> {
751        let workspace = tempfile::tempdir().context("workspace")?;
752        let thread_id =
753            format!("selector_capture_{}_{}", std::process::id(), rand::random::<u64>());
754        let _cleanup = SelectorRecordCleanup(thread_id.clone());
755        let mut state = crate::state::bash_state::BashState::new();
756        state.current_thread_id = thread_id.clone();
757        state.workspace_root = workspace.path().canonicalize().context("canonical workspace")?;
758        state.cwd = state.workspace_root.clone();
759        state.initialized = true;
760        state.init_pty_shell().await.context("PTY")?;
761        let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(state)));
762        selector_runtime_command(
763            &state,
764            &thread_id,
765            "claude() { printf 'fake backend accepted selector: %s\\n' \"$*\"; }",
766            false,
767        )
768        .await
769        .context("install controlled shell fixture")?;
770        let id = "550e8400-e29b-41d4-a716-446655440000";
771        let outcome = selector_runtime_command(
772            &state,
773            &thread_id,
774            &format!("claude --session-id {id}"),
775            false,
776        )
777        .await
778        .context("accepted launch")?;
779        assert!(outcome.command_generation.is_some(), "fixture must actually start a command");
780        let recorded = crate::state::agent_resume::load_agent_session(&thread_id)
781            .context("reload persisted record")?
782            .context("accepted agent recorded")?;
783        assert_eq!(recorded.resume_command(), format!("claude --resume {id}"));
784        let encoded = serde_json::to_value(&recorded).context("serialize")?;
785        assert_eq!(encoded["conversation_selector"]["source"], "launch_argument");
786        assert_eq!(encoded["conversation_selector"]["id"], id);
787        assert_eq!(
788            encoded["workspace_root"],
789            workspace.path().canonicalize().context("workspace")?.to_string_lossy().as_ref()
790        );
791        assert_eq!(encoded["scoped_thread_id"], thread_id);
792        // This controls argv only. The fixture makes no claim about a real backend's session.
793        selector_runtime_command(
794            &state,
795            &thread_id,
796            "printf 'accepted non-agent command\\n'",
797            false,
798        )
799        .await
800        .context("accepted non-agent command")?;
801        assert!(crate::state::agent_resume::load_agent_session(&thread_id)
802            .context("cleared record")?
803            .is_none());
804        Ok(())
805    }
806
807    #[cfg(unix)]
808    #[tokio::test]
809    async fn selector_recovery_busy_and_background_requests_preserve_the_foreground_record(
810    ) -> anyhow::Result<()> {
811        let workspace = tempfile::tempdir().context("workspace")?;
812        let thread_id = format!("selector_busy_{}_{}", std::process::id(), rand::random::<u64>());
813        let _cleanup = SelectorRecordCleanup(thread_id.clone());
814        let mut state = crate::state::bash_state::BashState::new();
815        state.current_thread_id = thread_id.clone();
816        state.workspace_root = workspace.path().canonicalize().context("canonical workspace")?;
817        state.cwd = state.workspace_root.clone();
818        state.initialized = true;
819        state.init_pty_shell().await.context("PTY")?;
820        let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(state)));
821        selector_runtime_command(&state, &thread_id, "claude() { sleep 1; }", false)
822            .await
823            .context("install controlled shell fixture")?;
824        let id = "550e8400-e29b-41d4-a716-446655440000";
825        selector_runtime_command(&state, &thread_id, &format!("claude --session-id {id}"), false)
826            .await
827            .context("agent launch")?;
828        let before = crate::state::agent_resume::load_agent_session(&thread_id)
829            .context("record")?
830            .context("foreground record")?;
831        let _ = selector_runtime_command(
832            &state,
833            &thread_id,
834            "printf 'must not replace a busy record'",
835            false,
836        )
837        .await;
838        assert_eq!(
839            crate::state::agent_resume::load_agent_session(&thread_id)
840                .context("record after busy request")?,
841            Some(before.clone())
842        );
843        let _ =
844            selector_runtime_command(&state, &thread_id, "printf 'independent background'", true)
845                .await;
846        assert_eq!(
847            crate::state::agent_resume::load_agent_session(&thread_id)
848                .context("record after background request")?,
849            Some(before)
850        );
851        Ok(())
852    }
853    #[test]
854    fn selector_recovery_failed_accepted_launch_persistence_invalidates_older_target(
855    ) -> anyhow::Result<()> {
856        let workspace = tempfile::tempdir().context("workspace")?;
857        let thread_id =
858            format!("selector_save_fail_{}_{}", std::process::id(), rand::random::<u64>());
859        let _cleanup = SelectorRecordCleanup(thread_id.clone());
860        let mut state = crate::state::bash_state::BashState::new();
861        state.current_thread_id = thread_id.clone();
862        state.workspace_root = workspace.path().canonicalize().context("workspace")?;
863        state.cwd = state.workspace_root.clone();
864        let id = "550e8400-e29b-41d4-a716-446655440000";
865        // This private writer is invoked only at the accepted foreground boundary.
866        super::record_foreground_agent_launch(
867            &state,
868            &format!("claude --session-id {id}"),
869            &state.cwd,
870        );
871        let prior = crate::state::agent_resume::load_agent_session(&thread_id)
872            .context("prior record")?
873            .context("accepted selector")?;
874        assert_eq!(prior.resume_command(), format!("claude --resume {id}"));
875        let oversized = format!("claude {}", "x".repeat(9 * 1024));
876        super::record_foreground_agent_launch(&state, &oversized, &state.cwd);
877        assert!(
878            crate::state::agent_resume::load_agent_session(&thread_id)
879                .context("record after failed persistence")?
880                .is_none(),
881            "a newer accepted launch must not leave the previous executable target"
882        );
883        Ok(())
884    }
885
886    #[cfg(unix)]
887    #[tokio::test]
888    async fn selector_recovery_launch_cwd_comes_from_the_shared_pty_after_a_prior_cd(
889    ) -> anyhow::Result<()> {
890        let workspace = tempfile::tempdir().context("workspace")?;
891        let root = workspace.path().canonicalize().context("canonical workspace")?;
892        let new_cwd = root.join("new");
893        std::fs::create_dir(&new_cwd).context("new working directory")?;
894        let thread_id =
895            format!("selector_stale_cwd_{}_{}", std::process::id(), rand::random::<u64>());
896        let _cleanup = SelectorRecordCleanup(thread_id.clone());
897        let mut state = crate::state::bash_state::BashState::new();
898        state.current_thread_id = thread_id.clone();
899        state.workspace_root = root.clone();
900        state.cwd = root.clone();
901        state.initialized = true;
902        state.init_pty_shell().await.context("PTY")?;
903        let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(state)));
904        selector_runtime_command(&state, &thread_id, "claude() { :; }", false)
905            .await
906            .context("controlled shell fixture")?;
907        // Queued calls already hold BashState clones sharing this PTY and gate.
908        // Keep that snapshot stale while another accepted call changes the real cwd.
909        let queued_snapshot = state.lock().await.as_ref().context("initialized state")?.clone();
910        let changed = selector_runtime_command(
911            &state,
912            &thread_id,
913            &format!("cd '{}'", new_cwd.display()),
914            false,
915        )
916        .await
917        .context("prior cd")?;
918        assert_eq!(changed.result.state.cwd, new_cwd, "PTY must report the completed cd");
919        assert_eq!(queued_snapshot.cwd, root, "the queued snapshot must remain stale");
920        let queued_slot = std::sync::Arc::new(tokio::sync::Mutex::new(Some(queued_snapshot)));
921        let id = "550e8400-e29b-41d4-a716-446655440000";
922        selector_runtime_command(
923            &queued_slot,
924            &thread_id,
925            &format!("claude --session-id {id}"),
926            false,
927        )
928        .await
929        .context("launch through stale snapshot")?;
930        let recorded = crate::state::agent_resume::load_agent_session(&thread_id)
931            .context("reload")?
932            .context("accepted selector record")?;
933        assert_eq!(
934            std::path::Path::new(&recorded.cwd),
935            new_cwd.as_path(),
936            "launch evidence must use cwd at PTY submission, not the queued snapshot"
937        );
938        assert_eq!(recorded.resume_command(), format!("claude --resume {id}"));
939        Ok(())
940    }
941}