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