1mod 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum BashProcessStatus {
44 Running,
45 Exited,
46}
47
48#[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#[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#[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
158const 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
168pub(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
214pub 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
222pub 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
231pub 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 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 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
439fn 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 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 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 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 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}