//! Process lifecycle tests against the stub agent: kill on drop, graceful //! close, stderr tail, handshake timeout, immediate exit and mid-prompt crash. mod common; use std::time::{Duration, Instant}; use common::{collect_until, handshake, spawn_stub, stub_pid, wait_until_gone}; use notescraft_acp::{AgentCommand, AgentProcess, Error, Event, PromptBlock}; const TIMEOUT: Duration = Duration::from_secs(15); #[test] fn dropping_the_handle_kills_the_agent() { let process = spawn_stub("hold"); let pid = stub_pid(&process); assert!(common::process_alive(pid), "stub agent should be running"); drop(process); assert!(wait_until_gone(pid, Duration::from_secs(5)), "agent {pid} survived the drop"); } #[test] fn close_shuts_down_gracefully_and_refuses_further_sends() { let mut process = spawn_stub("hold"); let pid = stub_pid(&process); match process.close() { Ok(()) => {} Err(err) => panic!("close failed: {err}"), } assert!(wait_until_gone(pid, Duration::from_secs(5)), "close left agent {pid} running"); // stdin is closed: further calls report it instead of panicking. match process.prompt("sess-1", &[PromptBlock::Text("x".to_string())]) { Err(Error::Closed) => {} Err(other) => panic!("unexpected error after close: {other}"), Ok(_) => panic!("prompt accepted after close"), } match process.close() { Ok(()) => {} Err(err) => panic!("second close failed: {err}"), } } #[test] fn stderr_tail_collects_agent_diagnostics() { let process = spawn_stub("hold"); let pid = stub_pid(&process); assert_eq!(process.stderr_tail().trim(), format!("PID {pid}")); drop(process); } #[test] fn handshake_times_out_when_the_agent_never_answers() { let process = spawn_stub("silent"); let started = Instant::now(); match process.initialize_blocking(Duration::from_millis(300)) { Err(Error::Timeout { timeout_ms }) => assert_eq!(timeout_ms, 300), Err(other) => panic!("unexpected error: {other}"), Ok(_) => panic!("silent agent answered the handshake"), } assert!(started.elapsed() < Duration::from_secs(5), "the handshake deadline was not honoured"); } #[test] fn agent_that_exits_immediately_is_reported() { let mut process = spawn_stub("exit"); let events = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::Exited { .. })); match events.last() { Some(Event::Exited { code }) => assert_eq!(*code, Some(0)), other => panic!("expected Exited{{code: 0}}, got {other:?}"), } match process.close() { Ok(()) => {} Err(err) => panic!("close after exit failed: {err}"), } } #[test] fn crash_mid_prompt_reports_the_exit_code() { let process = spawn_stub("crash"); let session_id = handshake(&process); process.prompt(&session_id, &[PromptBlock::Text("die".to_string())]).unwrap_or_else(|err| panic!("prompt failed: {err}")); let events = collect_until(process.events(), TIMEOUT, |event| matches!(event, Event::Exited { .. })); match events.last() { Some(Event::Exited { code }) => assert_eq!(*code, Some(3)), other => panic!("expected Exited{{code: 3}}, got {other:?}"), } } #[test] fn spawning_a_missing_binary_is_an_error_not_a_panic() { let command = match AgentCommand::new("notescraft-acp-no-such-agent-binary", std::env::temp_dir(), Vec::new()) { Ok(command) => command, Err(err) => panic!("command line should be valid: {err}"), }; match AgentProcess::spawn(command) { Err(Error::Spawn(_)) => {} Err(other) => panic!("unexpected error: {other}"), Ok(process) => { drop(process); panic!("spawning a missing binary succeeded"); } } }