1mod 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum BashProcessStatus {
43 Running,
44 Exited,
45}
46
47#[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#[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#[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
157const 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
167pub(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
213pub 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
221pub 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
230pub 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 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 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
438fn 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 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 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 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 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}