1use std::collections::HashMap;
192use std::future::Future;
193use std::pin::Pin;
194use std::process::Stdio;
195use std::sync::Arc;
196use std::time::Duration;
197
198use serde_json::Value;
199use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
200use tokio::process::{Child, ChildStdin, ChildStdout, Command};
201use tokio::sync::{broadcast, mpsc, oneshot, watch};
202use tokio::task::JoinHandle;
203use tracing::{Instrument, debug, warn};
204
205use crate::Claude;
206use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
207use crate::error::{Error, Result};
208use crate::tool_pattern::ToolPattern;
209use crate::types::{Effort, HermeticScope, PermissionMode};
210
211pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
216
217#[derive(Debug, Clone)]
226pub struct PermissionRequest {
227 pub request_id: String,
230 pub tool_name: String,
232 pub input: Value,
234 pub raw: Value,
237}
238
239#[derive(Debug, Clone)]
248pub enum PermissionDecision {
249 Allow {
251 updated_input: Option<Value>,
254 },
255 Deny {
257 message: String,
259 },
260 Defer,
263}
264
265type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
266type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
267
268#[derive(Clone)]
281pub struct PermissionHandler {
282 inner: Arc<PermissionFn>,
283}
284
285impl PermissionHandler {
286 pub fn new<F, Fut>(f: F) -> Self
302 where
303 F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
304 Fut: Future<Output = PermissionDecision> + Send + 'static,
305 {
306 Self {
307 inner: Arc::new(move |req| Box::pin(f(req))),
308 }
309 }
310
311 fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
312 (self.inner)(req)
313 }
314}
315
316impl std::fmt::Debug for PermissionHandler {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 f.debug_struct("PermissionHandler").finish_non_exhaustive()
319 }
320}
321
322#[derive(Debug, Default, Clone)]
350pub struct DuplexOptions {
351 shared: SharedSpawnArgs,
354 additional_args: Vec<String>,
355 subscriber_capacity: Option<usize>,
356 on_permission: Option<PermissionHandler>,
357}
358
359impl DuplexOptions {
360 #[must_use]
362 pub fn model(mut self, model: impl Into<String>) -> Self {
363 self.shared.model = Some(model.into());
364 self
365 }
366
367 #[must_use]
369 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
370 self.shared.system_prompt = Some(prompt.into());
371 self
372 }
373
374 #[must_use]
376 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
377 self.shared.append_system_prompt = Some(prompt.into());
378 self
379 }
380
381 #[must_use]
398 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
399 self.shared.resume = Some(session_id.into());
400 self
401 }
402
403 #[must_use]
410 pub fn continue_session(mut self) -> Self {
411 self.shared.continue_session = true;
412 self
413 }
414
415 #[must_use]
426 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
427 self.shared.worktree = true;
428 if let Some(n) = name {
429 self.shared.worktree_name = Some(n.into());
430 }
431 self
432 }
433
434 #[must_use]
448 pub fn agent(mut self, name: impl Into<String>) -> Self {
449 self.shared.agent = Some(name.into());
450 self
451 }
452
453 #[must_use]
465 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
466 self.shared.agents_json = Some(json.into());
467 self
468 }
469
470 #[must_use]
486 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
487 self.shared.permission_mode = Some(mode);
488 self
489 }
490
491 #[must_use]
499 pub fn dangerously_skip_permissions(mut self) -> Self {
500 self.shared.dangerously_skip_permissions = true;
501 self
502 }
503
504 #[must_use]
514 pub fn session_id(mut self, id: impl Into<String>) -> Self {
515 self.shared.session_id = Some(id.into());
516 self
517 }
518
519 #[must_use]
527 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
528 self.shared.json_schema = Some(schema.into());
529 self
530 }
531
532 #[must_use]
540 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
541 where
542 I: IntoIterator<Item = T>,
543 T: Into<ToolPattern>,
544 {
545 self.shared
546 .allowed_tools
547 .extend(tools.into_iter().map(Into::into));
548 self
549 }
550
551 #[must_use]
553 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
554 self.shared.allowed_tools.push(tool.into());
555 self
556 }
557
558 #[must_use]
560 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
561 where
562 I: IntoIterator<Item = T>,
563 T: Into<ToolPattern>,
564 {
565 self.shared
566 .disallowed_tools
567 .extend(tools.into_iter().map(Into::into));
568 self
569 }
570
571 #[must_use]
573 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
574 self.shared.disallowed_tools.push(tool.into());
575 self
576 }
577
578 #[must_use]
584 pub fn max_turns(mut self, turns: u32) -> Self {
585 self.shared.max_turns = Some(turns);
586 self
587 }
588
589 #[must_use]
599 pub fn max_budget_usd(mut self, budget: f64) -> Self {
600 self.shared.max_budget_usd = Some(budget);
601 self
602 }
603
604 #[must_use]
607 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
608 self.shared.fallback_model = Some(model.into());
609 self
610 }
611
612 #[must_use]
614 pub fn effort(mut self, effort: Effort) -> Self {
615 self.shared.effort = Some(effort);
616 self
617 }
618
619 #[must_use]
622 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
623 self.shared.add_dir.push(dir.into());
624 self
625 }
626
627 #[must_use]
632 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
633 self.shared.mcp_config.push(path.into());
634 self
635 }
636
637 #[must_use]
641 pub fn strict_mcp_config(mut self) -> Self {
642 self.shared.strict_mcp_config = true;
643 self
644 }
645
646 #[must_use]
652 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
653 self.shared.setting_sources = Some(sources.into());
654 self
655 }
656
657 #[must_use]
670 pub fn hermetic(mut self) -> Self {
671 self.shared.apply_hermetic(HermeticScope::Full);
672 self
673 }
674
675 #[must_use]
681 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
682 self.shared.apply_hermetic(scope);
683 self
684 }
685
686 #[must_use]
689 pub fn no_session_persistence(mut self) -> Self {
690 self.shared.no_session_persistence = true;
691 self
692 }
693
694 #[must_use]
702 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
703 self.shared.tools.extend(tools.into_iter().map(Into::into));
704 self
705 }
706
707 #[must_use]
712 pub fn file(mut self, spec: impl Into<String>) -> Self {
713 self.shared.file.push(spec.into());
714 self
715 }
716
717 #[must_use]
721 pub fn settings(mut self, settings: impl Into<String>) -> Self {
722 self.shared.settings = Some(settings.into());
723 self
724 }
725
726 #[must_use]
733 pub fn fork_session(mut self) -> Self {
734 self.shared.fork_session = true;
735 self
736 }
737
738 #[must_use]
742 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
743 self.shared.debug_filter = Some(filter.into());
744 self
745 }
746
747 #[must_use]
751 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
752 self.shared.debug_file = Some(path.into());
753 self
754 }
755
756 #[must_use]
760 pub fn betas(mut self, betas: impl Into<String>) -> Self {
761 self.shared.betas = Some(betas.into());
762 self
763 }
764
765 #[must_use]
769 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
770 self.shared.plugin_dirs.push(dir.into());
771 self
772 }
773
774 #[must_use]
778 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
779 self.shared.plugin_urls.push(url.into());
780 self
781 }
782
783 #[must_use]
787 pub fn tmux(mut self) -> Self {
788 self.shared.tmux = true;
789 self
790 }
791
792 #[must_use]
800 pub fn bare(mut self) -> Self {
801 self.shared.bare = true;
802 self
803 }
804
805 #[must_use]
812 pub fn safe_mode(mut self) -> Self {
813 self.shared.safe_mode = true;
814 self
815 }
816
817 #[must_use]
822 pub fn disable_slash_commands(mut self) -> Self {
823 self.shared.disable_slash_commands = true;
824 self
825 }
826
827 #[must_use]
834 pub fn include_hook_events(mut self) -> Self {
835 self.shared.include_hook_events = true;
836 self
837 }
838
839 #[must_use]
848 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
849 self.shared.exclude_dynamic_system_prompt_sections = true;
850 self
851 }
852
853 #[must_use]
857 pub fn name(mut self, name: impl Into<String>) -> Self {
858 self.shared.name = Some(name.into());
859 self
860 }
861
862 #[must_use]
867 pub fn arg(mut self, arg: impl Into<String>) -> Self {
868 self.additional_args.push(arg.into());
869 self
870 }
871
872 #[must_use]
880 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
881 self.subscriber_capacity = Some(capacity);
882 self
883 }
884
885 #[must_use]
903 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
904 self.on_permission = Some(handler);
905 self
906 }
907
908 fn build_args(&self) -> Vec<String> {
909 let mut args = vec![
910 "--print".to_string(),
911 "--verbose".to_string(),
912 "--output-format".to_string(),
913 "stream-json".to_string(),
914 "--input-format".to_string(),
915 "stream-json".to_string(),
916 ];
917
918 self.shared.append_to(&mut args);
919
920 if self.on_permission.is_some() {
921 args.push("--permission-prompt-tool".to_string());
922 args.push("stdio".to_string());
923 }
924 args.extend(self.additional_args.iter().cloned());
925
926 args
927 }
928
929 fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
935 let mut args = claude.global_args.clone();
936 args.extend(self.build_args());
937 args
938 }
939
940 #[must_use]
969 pub fn to_command_string(&self, claude: &Claude) -> String {
970 let args = self.spawn_command_args(claude);
971 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
972 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
973 }
974}
975
976#[derive(Debug, Clone)]
983pub struct TurnResult {
984 pub result: Value,
986 pub events: Vec<Value>,
988}
989
990impl TurnResult {
991 #[must_use]
993 pub fn result_text(&self) -> Option<&str> {
994 self.result.get("result").and_then(Value::as_str)
995 }
996
997 #[must_use]
999 pub fn session_id(&self) -> Option<&str> {
1000 self.result.get("session_id").and_then(Value::as_str)
1001 }
1002
1003 #[must_use]
1010 pub fn total_cost_usd(&self) -> Option<f64> {
1011 self.result
1012 .get("total_cost_usd")
1013 .or_else(|| self.result.get("cost_usd"))
1014 .and_then(Value::as_f64)
1015 }
1016
1017 #[must_use]
1019 pub fn duration_ms(&self) -> Option<u64> {
1020 self.result.get("duration_ms").and_then(Value::as_u64)
1021 }
1022}
1023
1024#[derive(Debug, Clone)]
1038pub enum InboundEvent {
1039 SystemInit {
1042 session_id: String,
1045 },
1046 Assistant(Value),
1049 StreamEvent(Value),
1052 User(Value),
1055 Other(Value),
1058}
1059
1060fn classify(msg: &Value) -> InboundEvent {
1061 match msg.get("type").and_then(Value::as_str) {
1062 Some("system") => {
1063 if msg.get("subtype").and_then(Value::as_str) == Some("init")
1064 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1065 {
1066 return InboundEvent::SystemInit {
1067 session_id: id.to_string(),
1068 };
1069 }
1070 InboundEvent::Other(msg.clone())
1071 }
1072 Some("assistant") => InboundEvent::Assistant(msg.clone()),
1073 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1074 Some("user") => InboundEvent::User(msg.clone()),
1075 _ => InboundEvent::Other(msg.clone()),
1076 }
1077}
1078
1079#[derive(Debug, Clone)]
1094pub enum SessionExitStatus {
1095 Running,
1097 Completed,
1100 Failed(String),
1103}
1104
1105#[derive(Debug)]
1114pub struct DuplexSession {
1115 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1116 events_tx: broadcast::Sender<InboundEvent>,
1117 exit_rx: watch::Receiver<SessionExitStatus>,
1118 join: JoinHandle<Result<()>>,
1119}
1120
1121#[derive(Debug)]
1122enum OutboundMsg {
1123 Send {
1124 prompt: String,
1125 reply: oneshot::Sender<Result<TurnResult>>,
1126 },
1127 PermissionResponse {
1128 request_id: String,
1129 decision: PermissionDecision,
1130 },
1131 Interrupt {
1132 reply: oneshot::Sender<Result<()>>,
1133 },
1134}
1135
1136impl DuplexSession {
1137 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1145 let capacity = opts
1146 .subscriber_capacity
1147 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1148 let permission_handler = opts.on_permission.clone();
1149
1150 let command_args = opts.spawn_command_args(claude);
1151
1152 debug!(
1153 binary = %claude.binary.display(),
1154 args = ?command_args,
1155 "spawning duplex claude session"
1156 );
1157
1158 let mut cmd = Command::new(&claude.binary);
1159 cmd.args(&command_args)
1160 .stdin(Stdio::piped())
1161 .stdout(Stdio::piped())
1162 .stderr(Stdio::piped())
1163 .kill_on_drop(true);
1164 crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1165 crate::exec::apply_process_group(&mut cmd, claude.process_group);
1169
1170 if let Some(ref dir) = claude.working_dir {
1171 cmd.current_dir(dir);
1172 }
1173
1174 let mut child = cmd.spawn().map_err(|e| Error::Io {
1175 message: format!("failed to spawn claude: {e}"),
1176 source: e,
1177 working_dir: claude.working_dir.clone(),
1178 })?;
1179 let group =
1180 crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1181
1182 let stdin = child.stdin.take().expect("stdin was piped");
1183 let stdout = child.stdout.take().expect("stdout was piped");
1184
1185 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1186 let (events_tx, _initial_rx) = broadcast::channel(capacity);
1187 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1188
1189 let session_span = tracing::debug_span!(
1198 "claude.session",
1199 session_id = tracing::field::Empty,
1200 model = opts.shared.model.as_deref().unwrap_or("default"),
1201 permission_mode = opts
1202 .shared
1203 .permission_mode
1204 .as_ref()
1205 .map(|m| m.as_arg())
1206 .unwrap_or("default"),
1207 resumed = opts.shared.resume.is_some(),
1208 turns = tracing::field::Empty,
1209 exit = tracing::field::Empty,
1210 );
1211 let join = tokio::spawn(
1212 run_session(
1213 child,
1214 group,
1215 claude.kill_grace,
1216 stdin,
1217 stdout,
1218 outbound_rx,
1219 events_tx.clone(),
1220 permission_handler,
1221 exit_tx,
1222 )
1223 .instrument(session_span),
1224 );
1225
1226 Ok(Self {
1227 outbound_tx,
1228 events_tx,
1229 exit_rx,
1230 join,
1231 })
1232 }
1233
1234 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1245 let (reply_tx, reply_rx) = oneshot::channel();
1246 self.outbound_tx
1247 .send(OutboundMsg::Send {
1248 prompt: prompt.into(),
1249 reply: reply_tx,
1250 })
1251 .map_err(|_| Error::DuplexClosed)?;
1252 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1253 }
1254
1255 #[must_use]
1290 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1291 self.events_tx.subscribe()
1292 }
1293
1294 #[must_use]
1305 pub fn is_alive(&self) -> bool {
1306 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1307 }
1308
1309 #[must_use]
1318 pub fn exit_status(&self) -> SessionExitStatus {
1319 self.exit_rx.borrow().clone()
1320 }
1321
1322 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1334 let mut rx = self.exit_rx.clone();
1335 loop {
1336 {
1337 let value = rx.borrow_and_update();
1338 if !matches!(*value, SessionExitStatus::Running) {
1339 return value.clone();
1340 }
1341 }
1342 if rx.changed().await.is_err() {
1343 return rx.borrow().clone();
1344 }
1345 }
1346 }
1347
1348 pub fn respond_to_permission(
1393 &self,
1394 request_id: impl Into<String>,
1395 decision: PermissionDecision,
1396 ) -> Result<()> {
1397 if matches!(decision, PermissionDecision::Defer) {
1398 warn!("respond_to_permission called with Defer; ignoring");
1399 return Ok(());
1400 }
1401 self.outbound_tx
1402 .send(OutboundMsg::PermissionResponse {
1403 request_id: request_id.into(),
1404 decision,
1405 })
1406 .map_err(|_| Error::DuplexClosed)?;
1407 Ok(())
1408 }
1409
1410 pub async fn interrupt(&self) -> Result<()> {
1451 let (reply_tx, reply_rx) = oneshot::channel();
1452 self.outbound_tx
1453 .send(OutboundMsg::Interrupt { reply: reply_tx })
1454 .map_err(|_| Error::DuplexClosed)?;
1455 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1456 }
1457
1458 pub async fn close(self) -> Result<()> {
1464 drop(self.outbound_tx);
1465 drop(self.events_tx);
1466 match self.join.await {
1467 Ok(result) => result,
1468 Err(e) if e.is_cancelled() => Ok(()),
1469 Err(e) => Err(Error::Io {
1470 message: format!("duplex session task panicked: {e}"),
1471 source: std::io::Error::other(e.to_string()),
1472 working_dir: None,
1473 }),
1474 }
1475 }
1476}
1477
1478const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1482
1483#[allow(clippy::too_many_arguments)]
1484async fn run_session(
1485 mut child: Child,
1486 mut group: crate::exec::GroupKillGuard,
1487 kill_grace: Option<Duration>,
1488 mut stdin: ChildStdin,
1489 stdout: ChildStdout,
1490 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1491 events_tx: broadcast::Sender<InboundEvent>,
1492 permission_handler: Option<PermissionHandler>,
1493 exit_tx: watch::Sender<SessionExitStatus>,
1494) -> Result<()> {
1495 let mut lines = BufReader::new(stdout).lines();
1496 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1497 let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1498 let mut next_control_id: u64 = 0;
1499 let mut stream_err: Option<Error> = None;
1500 let session_span = tracing::Span::current();
1502 let mut turns: u64 = 0;
1503 let mut turn_span: Option<tracing::Span> = None;
1504 let mut turn_started: Option<std::time::Instant> = None;
1505
1506 loop {
1507 tokio::select! {
1508 biased;
1509
1510 line = lines.next_line() => match line {
1511 Ok(Some(l)) => {
1512 if l.trim().is_empty() {
1513 continue;
1514 }
1515 let parsed = match serde_json::from_str::<Value>(&l) {
1516 Ok(v) => v,
1517 Err(e) => {
1518 debug!(line = %l, error = %e, "failed to parse duplex event, skipping");
1519 continue;
1520 }
1521 };
1522 match parsed.get("type").and_then(Value::as_str) {
1526 Some("system")
1527 if parsed.get("subtype").and_then(Value::as_str) == Some("init") =>
1528 {
1529 if let Some(id) = parsed.get("session_id").and_then(Value::as_str) {
1530 session_span.record("session_id", id);
1531 }
1532 }
1533 Some("result") => {
1534 if let Some(span) = turn_span.take() {
1535 span.record(
1536 "is_error",
1537 parsed.get("is_error").and_then(Value::as_bool).unwrap_or(false),
1538 );
1539 if let Some(sub) = parsed.get("subtype").and_then(Value::as_str) {
1540 span.record("subtype", sub);
1541 }
1542 if let Some(c) =
1543 parsed.get("total_cost_usd").and_then(Value::as_f64)
1544 {
1545 span.record("cost_usd", c);
1546 }
1547 if let Some(started) = turn_started.take() {
1548 span.record(
1549 "duration_ms",
1550 started.elapsed().as_millis() as u64,
1551 );
1552 }
1553 }
1554 }
1555 _ => {}
1556 }
1557 match handle_inbound(parsed, &mut pending, &events_tx) {
1558 InboundAction::None => {}
1559 InboundAction::Permission(req) => {
1560 let request_id = req.request_id.clone();
1561 let decision = match permission_handler.as_ref() {
1562 Some(h) => h.invoke(req).await,
1563 None => {
1564 warn!(
1565 request_id = %request_id,
1566 "received can_use_tool with no permission handler; auto-denying"
1567 );
1568 PermissionDecision::Deny {
1569 message:
1570 "no permission handler configured on duplex session"
1571 .into(),
1572 }
1573 }
1574 };
1575 if matches!(decision, PermissionDecision::Defer) {
1576 debug!(
1577 request_id = %request_id,
1578 "permission handler deferred; waiting for respond_to_permission"
1579 );
1580 } else if let Err(e) =
1581 write_permission_response(&mut stdin, &request_id, &decision).await
1582 {
1583 warn!(error = %e, "failed to write permission response");
1584 }
1585 }
1586 InboundAction::ControlResponse { request_id, outcome } => {
1587 if let Some(reply) = pending_control.remove(&request_id) {
1588 let _ = reply.send(outcome);
1589 } else {
1590 debug!(
1591 request_id = %request_id,
1592 "received control_response with no pending request"
1593 );
1594 }
1595 }
1596 }
1597 }
1598 Ok(None) => break,
1599 Err(e) => {
1600 stream_err = Some(Error::Io {
1601 message: "failed to read duplex stdout".to_string(),
1602 source: e,
1603 working_dir: None,
1604 });
1605 break;
1606 }
1607 },
1608
1609 msg = outbound_rx.recv() => match msg {
1610 Some(OutboundMsg::Send { prompt, reply }) => {
1611 if pending.is_some() {
1612 let _ = reply.send(Err(Error::DuplexTurnInFlight));
1613 continue;
1614 }
1615 if let Err(e) = write_user(&mut stdin, &prompt).await {
1616 let _ = reply.send(Err(e));
1617 continue;
1618 }
1619 turns += 1;
1620 turn_span = Some(tracing::debug_span!(
1625 parent: &session_span,
1626 "claude.turn",
1627 turn = turns,
1628 is_error = tracing::field::Empty,
1629 subtype = tracing::field::Empty,
1630 cost_usd = tracing::field::Empty,
1631 duration_ms = tracing::field::Empty,
1632 ));
1633 turn_started = Some(std::time::Instant::now());
1634 pending = Some((reply, Vec::new()));
1635 }
1636 Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1637 if let Err(e) =
1638 write_permission_response(&mut stdin, &request_id, &decision).await
1639 {
1640 warn!(error = %e, "failed to write deferred permission response");
1641 }
1642 }
1643 Some(OutboundMsg::Interrupt { reply }) => {
1644 next_control_id += 1;
1645 let request_id = format!("interrupt-{next_control_id}");
1646 if let Err(e) =
1647 write_control_request(&mut stdin, &request_id, "interrupt").await
1648 {
1649 let _ = reply.send(Err(e));
1650 continue;
1651 }
1652 pending_control.insert(request_id, reply);
1653 }
1654 None => break,
1655 },
1656 }
1657 }
1658
1659 drop(stdin);
1660 match tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()).await {
1661 Ok(Ok(_status)) => {
1662 group.disarm();
1663 }
1664 Ok(Err(e)) => {
1665 warn!(error = %e, "failed to wait for duplex child");
1666 }
1667 Err(_) => {
1668 warn!("duplex child did not exit within shutdown budget; killing");
1669 crate::exec::kill_group_with_grace(&mut group, kill_grace).await;
1673 let _ = child.kill().await;
1674 }
1675 }
1676
1677 if let Some((reply, _)) = pending.take() {
1678 let _ = reply.send(Err(Error::DuplexClosed));
1679 }
1680 for (_, reply) in pending_control.drain() {
1681 let _ = reply.send(Err(Error::DuplexClosed));
1682 }
1683
1684 let result = match stream_err {
1685 Some(e) => Err(e),
1686 None => Ok(()),
1687 };
1688 let final_state = match &result {
1689 Ok(()) => SessionExitStatus::Completed,
1690 Err(e) => SessionExitStatus::Failed(e.to_string()),
1691 };
1692 session_span.record("turns", turns);
1693 session_span.record(
1694 "exit",
1695 match &final_state {
1696 SessionExitStatus::Completed => "completed",
1697 SessionExitStatus::Failed(_) => "failed",
1698 SessionExitStatus::Running => "running",
1699 },
1700 );
1701 let _ = exit_tx.send(final_state);
1702 result
1703}
1704
1705enum InboundAction {
1709 None,
1711 Permission(PermissionRequest),
1715 ControlResponse {
1720 request_id: String,
1721 outcome: Result<()>,
1722 },
1723}
1724
1725fn handle_inbound(
1726 msg: Value,
1727 pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1728 events_tx: &broadcast::Sender<InboundEvent>,
1729) -> InboundAction {
1730 match msg.get("type").and_then(Value::as_str) {
1731 Some("result") => {
1732 if let Some((reply, events)) = pending.take() {
1733 let _ = reply.send(Ok(TurnResult {
1734 result: msg,
1735 events,
1736 }));
1737 } else {
1738 debug!("dropping orphan result event with no pending turn");
1739 }
1740 InboundAction::None
1741 }
1742 Some("control_request") => {
1743 if msg
1746 .get("request")
1747 .and_then(|r| r.get("subtype"))
1748 .and_then(Value::as_str)
1749 == Some("can_use_tool")
1750 && let Some(req) = parse_permission_request(&msg)
1751 {
1752 if let Some((_, events)) = pending.as_mut() {
1753 events.push(msg);
1754 }
1755 return InboundAction::Permission(req);
1756 }
1757 debug!(
1758 ?msg,
1759 "received unhandled control_request; treating as Other"
1760 );
1761 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1762 if let Some((_, events)) = pending.as_mut() {
1763 events.push(msg);
1764 }
1765 InboundAction::None
1766 }
1767 Some("control_response") => {
1768 if let Some((request_id, outcome)) = parse_control_response(&msg) {
1769 return InboundAction::ControlResponse {
1770 request_id,
1771 outcome,
1772 };
1773 }
1774 debug!(
1775 ?msg,
1776 "received malformed control_response; treating as Other"
1777 );
1778 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1779 if let Some((_, events)) = pending.as_mut() {
1780 events.push(msg);
1781 }
1782 InboundAction::None
1783 }
1784 _ => {
1785 let _ = events_tx.send(classify(&msg));
1788
1789 if let Some((_, events)) = pending.as_mut() {
1790 events.push(msg);
1791 } else {
1792 debug!("dropping inbound event with no pending turn");
1793 }
1794 InboundAction::None
1795 }
1796 }
1797}
1798
1799fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
1800 let request_id = msg.get("request_id").and_then(Value::as_str)?;
1801 let request = msg.get("request")?;
1802 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
1803 let input = request.get("input").cloned().unwrap_or(Value::Null);
1804 Some(PermissionRequest {
1805 request_id: request_id.to_string(),
1806 tool_name: tool_name.to_string(),
1807 input,
1808 raw: request.clone(),
1809 })
1810}
1811
1812fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
1818 let response = msg.get("response")?;
1819 let request_id = response.get("request_id").and_then(Value::as_str)?;
1820 let outcome = match response.get("subtype").and_then(Value::as_str) {
1821 Some("success") => Ok(()),
1822 Some("error") => {
1823 let message = response
1824 .get("error")
1825 .and_then(Value::as_str)
1826 .unwrap_or("unknown control_response error")
1827 .to_string();
1828 Err(Error::DuplexControlFailed { message })
1829 }
1830 _ => return None,
1831 };
1832 Some((request_id.to_string(), outcome))
1833}
1834
1835async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
1836 let user_msg = serde_json::json!({
1837 "type": "user",
1838 "message": {
1839 "role": "user",
1840 "content": prompt,
1841 },
1842 "parent_tool_use_id": null,
1843 });
1844 write_line(stdin, &user_msg, "user message").await
1845}
1846
1847async fn write_control_request(
1848 stdin: &mut ChildStdin,
1849 request_id: &str,
1850 subtype: &str,
1851) -> Result<()> {
1852 let envelope = serde_json::json!({
1853 "type": "control_request",
1854 "request_id": request_id,
1855 "request": { "subtype": subtype },
1856 });
1857 write_line(stdin, &envelope, "control_request").await
1858}
1859
1860async fn write_permission_response(
1861 stdin: &mut ChildStdin,
1862 request_id: &str,
1863 decision: &PermissionDecision,
1864) -> Result<()> {
1865 let inner = match decision {
1866 PermissionDecision::Allow { updated_input } => {
1867 let mut obj = serde_json::Map::new();
1868 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
1869 if let Some(input) = updated_input {
1870 obj.insert("updatedInput".to_string(), input.clone());
1871 }
1872 Value::Object(obj)
1873 }
1874 PermissionDecision::Deny { message } => serde_json::json!({
1875 "behavior": "deny",
1876 "message": message,
1877 }),
1878 PermissionDecision::Defer => {
1879 return Ok(());
1881 }
1882 };
1883 let envelope = serde_json::json!({
1884 "type": "control_response",
1885 "response": {
1886 "request_id": request_id,
1887 "subtype": "success",
1888 "response": inner,
1889 },
1890 });
1891 write_line(stdin, &envelope, "control_response").await
1892}
1893
1894async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
1895 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
1896 message: format!("failed to serialize duplex {what}"),
1897 source: e,
1898 })?;
1899 line.push('\n');
1900 stdin
1901 .write_all(line.as_bytes())
1902 .await
1903 .map_err(|e| Error::Io {
1904 message: format!("failed to write {what} to duplex stdin"),
1905 source: e,
1906 working_dir: None,
1907 })?;
1908 stdin.flush().await.map_err(|e| Error::Io {
1909 message: "failed to flush duplex stdin".to_string(),
1910 source: e,
1911 working_dir: None,
1912 })?;
1913 Ok(())
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918 use super::*;
1919 use serde_json::json;
1920
1921 #[test]
1922 fn build_args_default_includes_required_flags() {
1923 let args = DuplexOptions::default().build_args();
1924 assert!(args.contains(&"--print".to_string()));
1925 assert!(args.contains(&"--verbose".to_string()));
1926 assert!(
1927 args.windows(2)
1928 .any(|w| w == ["--output-format", "stream-json"])
1929 );
1930 assert!(
1931 args.windows(2)
1932 .any(|w| w == ["--input-format", "stream-json"])
1933 );
1934 }
1935
1936 #[test]
1937 fn build_args_includes_model() {
1938 let args = DuplexOptions::default().model("haiku").build_args();
1939 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1940 }
1941
1942 #[test]
1943 fn build_args_includes_system_prompts() {
1944 let args = DuplexOptions::default()
1945 .system_prompt("be concise")
1946 .append_system_prompt("also polite")
1947 .build_args();
1948 assert!(
1949 args.windows(2)
1950 .any(|w| w == ["--system-prompt", "be concise"])
1951 );
1952 assert!(
1953 args.windows(2)
1954 .any(|w| w == ["--append-system-prompt", "also polite"])
1955 );
1956 }
1957
1958 #[test]
1959 fn build_args_appends_raw_args_last() {
1960 let args = DuplexOptions::default()
1961 .arg("--add-dir")
1962 .arg("/tmp/foo")
1963 .build_args();
1964 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
1966 }
1967
1968 fn preview_claude() -> Claude {
1971 Claude::builder()
1972 .binary("/usr/local/bin/claude")
1973 .build()
1974 .unwrap()
1975 }
1976
1977 #[test]
1978 fn spawn_command_args_prepends_global_args() {
1979 let claude = Claude::builder()
1980 .binary("/usr/local/bin/claude")
1981 .arg("--debug")
1982 .build()
1983 .unwrap();
1984 let args = DuplexOptions::default()
1985 .model("haiku")
1986 .spawn_command_args(&claude);
1987 assert_eq!(args[0], "--debug");
1988 assert_eq!(args[1], "--print");
1989 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1990 }
1991
1992 #[test]
1993 fn to_command_string_is_binary_plus_spawn_args() {
1994 let claude = Claude::builder()
1997 .binary("/usr/local/bin/claude")
1998 .arg("--debug")
1999 .build()
2000 .unwrap();
2001 let opts = DuplexOptions::default().agent("reviewer");
2002 let expected = format!(
2003 "/usr/local/bin/claude {}",
2004 opts.spawn_command_args(&claude).join(" ")
2005 );
2006 assert_eq!(opts.to_command_string(&claude), expected);
2007 }
2008
2009 #[test]
2010 fn to_command_string_includes_persona_flags() {
2011 let command_str = DuplexOptions::default()
2014 .agent("reviewer")
2015 .allowed_tool("Read")
2016 .allowed_tool("Bash(git:*)")
2017 .setting_sources("project")
2018 .to_command_string(&preview_claude());
2019 assert!(command_str.starts_with("/usr/local/bin/claude"));
2020 assert!(command_str.contains("--agent reviewer"));
2021 assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2022 assert!(command_str.contains("--setting-sources project"));
2023 }
2024
2025 #[test]
2026 fn to_command_string_quotes_args_with_spaces() {
2027 let command_str = DuplexOptions::default()
2028 .system_prompt("be concise")
2029 .to_command_string(&preview_claude());
2030 assert!(command_str.contains("--system-prompt 'be concise'"));
2031 }
2032
2033 #[test]
2034 fn to_command_string_does_not_consume_options() {
2035 let claude = preview_claude();
2038 let opts = DuplexOptions::default().model("haiku");
2039 let first = opts.to_command_string(&claude);
2040 let second = opts.to_command_string(&claude);
2041 assert_eq!(first, second);
2042 }
2043
2044 #[test]
2045 fn build_args_includes_resume_when_set() {
2046 let args = DuplexOptions::default().resume("abc-123").build_args();
2047 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
2048 }
2049
2050 #[test]
2051 fn build_args_omits_resume_by_default() {
2052 let args = DuplexOptions::default().build_args();
2053 assert!(
2054 !args.iter().any(|a| a == "--resume"),
2055 "--resume should not appear without an explicit resume(...) call; got {args:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn build_args_includes_continue_when_set() {
2061 let args = DuplexOptions::default().continue_session().build_args();
2062 assert!(args.iter().any(|a| a == "--continue"));
2063 }
2064
2065 #[test]
2066 fn build_args_omits_continue_by_default() {
2067 let args = DuplexOptions::default().build_args();
2068 assert!(!args.iter().any(|a| a == "--continue"));
2069 }
2070
2071 #[test]
2072 fn build_args_includes_worktree_flag_without_name() {
2073 let args = DuplexOptions::default().worktree(None::<&str>).build_args();
2074 assert!(args.iter().any(|a| a == "--worktree"));
2075 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2077 assert!(
2078 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
2079 "--worktree without a name should not be followed by a positional; got {args:?}"
2080 );
2081 }
2082
2083 #[test]
2084 fn build_args_includes_worktree_flag_with_name() {
2085 let args = DuplexOptions::default()
2086 .worktree(Some("agent-xyz"))
2087 .build_args();
2088 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2089 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
2090 }
2091
2092 #[test]
2093 fn build_args_omits_worktree_by_default() {
2094 let args = DuplexOptions::default().build_args();
2095 assert!(
2096 !args.iter().any(|a| a == "--worktree"),
2097 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
2098 );
2099 }
2100
2101 #[test]
2102 fn worktree_lands_before_additional_args() {
2103 let args = DuplexOptions::default()
2105 .worktree(Some("foo"))
2106 .arg("--")
2107 .arg("trailing")
2108 .build_args();
2109 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
2110 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2111 assert!(
2112 wt_pos < dash_dash_pos,
2113 "--worktree must precede `--` separator; got {args:?}"
2114 );
2115 }
2116
2117 #[test]
2118 fn build_args_includes_agent_when_set() {
2119 let args = DuplexOptions::default().agent("rust-qa").build_args();
2120 assert!(
2121 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
2122 "missing --agent rust-qa in {args:?}"
2123 );
2124 }
2125
2126 #[test]
2127 fn build_args_omits_agent_by_default() {
2128 let args = DuplexOptions::default().build_args();
2129 assert!(
2130 !args.iter().any(|a| a == "--agent"),
2131 "--agent should not appear without an explicit agent(...) call; got {args:?}"
2132 );
2133 }
2134
2135 #[test]
2136 fn build_args_includes_agents_json_when_set() {
2137 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2138 let args = DuplexOptions::default().agents_json(json).build_args();
2139 let pos = args.iter().position(|a| a == "--agents").unwrap();
2140 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
2141 }
2142
2143 #[test]
2144 fn build_args_omits_agents_json_by_default() {
2145 let args = DuplexOptions::default().build_args();
2146 assert!(!args.iter().any(|a| a == "--agents"));
2147 }
2148
2149 #[test]
2150 fn agent_and_agents_json_compose() {
2151 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2152 let args = DuplexOptions::default()
2153 .agents_json(json)
2154 .agent("reviewer")
2155 .build_args();
2156 assert!(args.iter().any(|a| a == "--agents"));
2158 assert!(args.iter().any(|a| a == "--agent"));
2159 }
2160
2161 #[test]
2162 fn agent_lands_before_additional_args() {
2163 let args = DuplexOptions::default()
2164 .agent("rust-qa")
2165 .arg("--")
2166 .arg("trailing")
2167 .build_args();
2168 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
2169 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2170 assert!(
2171 agent_pos < dash_dash_pos,
2172 "--agent must precede `--` separator; got {args:?}"
2173 );
2174 }
2175
2176 #[test]
2177 fn agents_json_lands_before_additional_args() {
2178 let args = DuplexOptions::default()
2179 .agents_json("{}")
2180 .arg("--")
2181 .arg("trailing")
2182 .build_args();
2183 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
2184 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2185 assert!(
2186 agents_pos < dash_dash_pos,
2187 "--agents must precede `--` separator; got {args:?}"
2188 );
2189 }
2190
2191 #[test]
2194 fn build_args_includes_session_id() {
2195 let args = DuplexOptions::default().session_id("sid-9").build_args();
2196 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
2197 }
2198
2199 #[test]
2200 fn build_args_includes_setting_sources() {
2201 let args = DuplexOptions::default()
2202 .setting_sources("user,project")
2203 .build_args();
2204 assert!(
2205 args.windows(2)
2206 .any(|w| w == ["--setting-sources", "user,project"]),
2207 "got {args:?}"
2208 );
2209 }
2210
2211 #[test]
2212 fn build_args_omits_setting_sources_by_default() {
2213 let args = DuplexOptions::default().build_args();
2214 assert!(!args.iter().any(|a| a == "--setting-sources"));
2215 }
2216
2217 #[test]
2218 fn build_args_hermetic_emits_full_seal() {
2219 let args = DuplexOptions::default().hermetic().build_args();
2220 assert!(
2221 args.windows(2)
2222 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
2223 "got {args:?}"
2224 );
2225 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2226 assert!(
2227 args.iter()
2228 .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2229 );
2230 assert!(!args.iter().any(|a| a == "--bare"));
2232 }
2233
2234 #[test]
2235 fn build_args_hermetic_scoped_project_keeps_user() {
2236 let args = DuplexOptions::default()
2237 .hermetic_scoped(HermeticScope::Project)
2238 .build_args();
2239 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2240 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2241 }
2242
2243 #[test]
2244 fn build_args_includes_json_schema() {
2245 let schema = r#"{"type":"object"}"#;
2246 let args = DuplexOptions::default().json_schema(schema).build_args();
2247 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2248 }
2249
2250 #[test]
2251 fn build_args_joins_allowed_tools_comma_separated() {
2252 let args = DuplexOptions::default()
2253 .allowed_tools(["Read", "Bash(git log:*)"])
2254 .allowed_tool("Write")
2255 .build_args();
2256 assert!(
2257 args.windows(2)
2258 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2259 "missing joined --allowed-tools in {args:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn build_args_joins_disallowed_tools_comma_separated() {
2265 let args = DuplexOptions::default()
2266 .disallowed_tools(["WebSearch"])
2267 .disallowed_tool("WebFetch")
2268 .build_args();
2269 assert!(
2270 args.windows(2)
2271 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2272 "missing joined --disallowed-tools in {args:?}"
2273 );
2274 }
2275
2276 #[test]
2277 fn build_args_includes_caps() {
2278 let args = DuplexOptions::default()
2279 .max_turns(4)
2280 .max_budget_usd(0.25)
2281 .build_args();
2282 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2283 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2284 }
2285
2286 #[test]
2287 fn build_args_includes_fallback_model_and_effort() {
2288 let args = DuplexOptions::default()
2289 .fallback_model("haiku")
2290 .effort(Effort::Low)
2291 .build_args();
2292 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2293 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2294 }
2295
2296 #[test]
2297 fn build_args_repeats_add_dir_and_mcp_config() {
2298 let args = DuplexOptions::default()
2299 .add_dir("/a")
2300 .add_dir("/b")
2301 .mcp_config("x.json")
2302 .strict_mcp_config()
2303 .build_args();
2304 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2305 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2306 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2307 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2308 }
2309
2310 #[test]
2311 fn build_args_includes_no_session_persistence() {
2312 let args = DuplexOptions::default()
2313 .no_session_persistence()
2314 .build_args();
2315 assert!(args.iter().any(|a| a == "--no-session-persistence"));
2316 }
2317
2318 #[test]
2321 fn build_args_joins_tools_comma_separated() {
2322 let args = DuplexOptions::default()
2323 .tools(["Bash", "Read", "Edit"])
2324 .build_args();
2325 assert!(
2326 args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2327 "missing joined --tools in {args:?}"
2328 );
2329 }
2330
2331 #[test]
2332 fn build_args_repeats_file_per_spec() {
2333 let args = DuplexOptions::default()
2334 .file("file_a:doc.txt")
2335 .file("file_b:notes.md")
2336 .build_args();
2337 assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2338 assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2339 assert!(args.iter().any(|a| a == "file_b:notes.md"));
2340 }
2341
2342 #[test]
2343 fn build_args_includes_settings() {
2344 let args = DuplexOptions::default()
2345 .settings("/tmp/settings.json")
2346 .build_args();
2347 assert!(
2348 args.windows(2)
2349 .any(|w| w == ["--settings", "/tmp/settings.json"])
2350 );
2351 }
2352
2353 #[test]
2354 fn build_args_includes_fork_session() {
2355 let args = DuplexOptions::default().fork_session().build_args();
2356 assert!(args.iter().any(|a| a == "--fork-session"));
2357 }
2358
2359 #[test]
2360 fn build_args_includes_debug_filter_and_file() {
2361 let args = DuplexOptions::default()
2362 .debug_filter("api,hooks")
2363 .debug_file("/tmp/debug.log")
2364 .build_args();
2365 assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2366 assert!(
2367 args.windows(2)
2368 .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2369 );
2370 }
2371
2372 #[test]
2373 fn build_args_includes_betas() {
2374 let args = DuplexOptions::default().betas("feature-x").build_args();
2375 assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2376 }
2377
2378 #[test]
2379 fn build_args_repeats_plugin_dir_and_url() {
2380 let args = DuplexOptions::default()
2381 .plugin_dir("/plugins/a")
2382 .plugin_dir("/plugins/b")
2383 .plugin_url("https://example.com/p.zip")
2384 .build_args();
2385 assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2386 assert!(
2387 args.windows(2)
2388 .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2389 );
2390 }
2391
2392 #[test]
2393 fn build_args_includes_bare_family_bool_flags() {
2394 let args = DuplexOptions::default()
2395 .tmux()
2396 .bare()
2397 .safe_mode()
2398 .disable_slash_commands()
2399 .include_hook_events()
2400 .exclude_dynamic_system_prompt_sections()
2401 .build_args();
2402 for flag in [
2403 "--tmux",
2404 "--bare",
2405 "--safe-mode",
2406 "--disable-slash-commands",
2407 "--include-hook-events",
2408 "--exclude-dynamic-system-prompt-sections",
2409 ] {
2410 assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2411 }
2412 }
2413
2414 #[test]
2415 fn build_args_includes_name() {
2416 let args = DuplexOptions::default().name("my session").build_args();
2417 assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2418 }
2419
2420 #[test]
2421 fn build_args_omits_promoted_parity_flags_by_default() {
2422 let args = DuplexOptions::default().build_args();
2423 for flag in [
2424 "--tools",
2425 "--file",
2426 "--settings",
2427 "--fork-session",
2428 "--debug",
2429 "--debug-file",
2430 "--betas",
2431 "--plugin-dir",
2432 "--plugin-url",
2433 "--tmux",
2434 "--bare",
2435 "--safe-mode",
2436 "--disable-slash-commands",
2437 "--include-hook-events",
2438 "--exclude-dynamic-system-prompt-sections",
2439 "--name",
2440 ] {
2441 assert!(
2442 !args.iter().any(|a| a == flag),
2443 "{flag} should be absent by default; got {args:?}"
2444 );
2445 }
2446 }
2447
2448 #[test]
2449 fn parity_flags_land_before_additional_args() {
2450 let args = DuplexOptions::default()
2452 .max_turns(2)
2453 .json_schema("{}")
2454 .arg("--")
2455 .arg("trailing")
2456 .build_args();
2457 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2458 for flag in ["--max-turns", "--json-schema"] {
2459 let pos = args.iter().position(|a| a == flag).unwrap();
2460 assert!(
2461 pos < dash_dash_pos,
2462 "{flag} must precede `--` separator; got {args:?}"
2463 );
2464 }
2465 }
2466
2467 #[test]
2468 fn build_args_omits_parity_flags_by_default() {
2469 let args = DuplexOptions::default().build_args();
2470 for flag in [
2471 "--session-id",
2472 "--json-schema",
2473 "--allowed-tools",
2474 "--disallowed-tools",
2475 "--max-turns",
2476 "--max-budget-usd",
2477 "--fallback-model",
2478 "--effort",
2479 "--add-dir",
2480 "--mcp-config",
2481 "--strict-mcp-config",
2482 "--no-session-persistence",
2483 ] {
2484 assert!(
2485 !args.iter().any(|a| a == flag),
2486 "{flag} should not appear by default; got {args:?}"
2487 );
2488 }
2489 }
2490
2491 #[test]
2492 fn resume_lands_before_additional_args() {
2493 let args = DuplexOptions::default()
2498 .resume("xyz")
2499 .arg("--")
2500 .arg("trailing")
2501 .build_args();
2502 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2503 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2504 assert!(
2505 resume_pos < dash_dash_pos,
2506 "--resume must precede `--` separator; got {args:?}"
2507 );
2508 }
2509
2510 #[test]
2511 fn turn_result_accessors_pull_from_result() {
2512 let r = TurnResult {
2513 result: json!({
2514 "type": "result",
2515 "result": "hello",
2516 "session_id": "sess-123",
2517 "total_cost_usd": 0.0042,
2518 "duration_ms": 1234_u64,
2519 }),
2520 events: vec![],
2521 };
2522 assert_eq!(r.result_text(), Some("hello"));
2523 assert_eq!(r.session_id(), Some("sess-123"));
2524 assert_eq!(r.total_cost_usd(), Some(0.0042));
2525 assert_eq!(r.duration_ms(), Some(1234));
2526 }
2527
2528 #[test]
2529 fn turn_result_total_cost_falls_back_to_legacy_field() {
2530 let r = TurnResult {
2531 result: json!({ "cost_usd": 0.5 }),
2532 events: vec![],
2533 };
2534 assert_eq!(r.total_cost_usd(), Some(0.5));
2535 }
2536
2537 #[test]
2538 fn turn_result_accessors_return_none_when_missing() {
2539 let r = TurnResult {
2540 result: json!({}),
2541 events: vec![],
2542 };
2543 assert_eq!(r.result_text(), None);
2544 assert_eq!(r.session_id(), None);
2545 assert_eq!(r.total_cost_usd(), None);
2546 assert_eq!(r.duration_ms(), None);
2547 }
2548
2549 #[test]
2550 fn handle_inbound_appends_non_result_to_pending_events() {
2551 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2552 let (events_tx, _events_rx) = broadcast::channel(16);
2553 let mut pending = Some((tx, Vec::new()));
2554 handle_inbound(
2555 json!({ "type": "assistant", "message": {} }),
2556 &mut pending,
2557 &events_tx,
2558 );
2559 let (_, events) = pending.as_ref().unwrap();
2560 assert_eq!(events.len(), 1);
2561 assert_eq!(
2562 events[0].get("type").and_then(Value::as_str),
2563 Some("assistant")
2564 );
2565 }
2566
2567 #[test]
2568 fn handle_inbound_resolves_pending_on_result() {
2569 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2570 let (events_tx, _events_rx) = broadcast::channel(16);
2571 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2572 handle_inbound(
2573 json!({ "type": "result", "result": "ok" }),
2574 &mut pending,
2575 &events_tx,
2576 );
2577 assert!(pending.is_none());
2578 let received = rx.blocking_recv().unwrap().unwrap();
2579 assert_eq!(received.result_text(), Some("ok"));
2580 assert_eq!(received.events.len(), 1);
2581 }
2582
2583 #[test]
2584 fn handle_inbound_drops_orphans_without_pending_turn() {
2585 let (events_tx, _events_rx) = broadcast::channel(16);
2586 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2587 handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2588 handle_inbound(
2589 json!({ "type": "result", "result": "ok" }),
2590 &mut pending,
2591 &events_tx,
2592 );
2593 assert!(pending.is_none());
2594 }
2595
2596 #[test]
2597 fn handle_inbound_broadcasts_classified_event() {
2598 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2599 let (events_tx, mut events_rx) = broadcast::channel(16);
2600 let mut pending = Some((tx, Vec::new()));
2601 handle_inbound(
2602 json!({ "type": "assistant", "message": { "role": "assistant" } }),
2603 &mut pending,
2604 &events_tx,
2605 );
2606 let event = events_rx.try_recv().expect("classified event broadcast");
2607 assert!(matches!(event, InboundEvent::Assistant(_)));
2608 }
2609
2610 #[test]
2611 fn handle_inbound_does_not_broadcast_result() {
2612 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2613 let (events_tx, mut events_rx) = broadcast::channel(16);
2614 let mut pending = Some((tx, Vec::new()));
2615 handle_inbound(
2616 json!({ "type": "result", "result": "ok" }),
2617 &mut pending,
2618 &events_tx,
2619 );
2620 assert!(events_rx.try_recv().is_err());
2622 }
2623
2624 #[test]
2625 fn classify_system_init_pulls_session_id() {
2626 let v = json!({
2627 "type": "system",
2628 "subtype": "init",
2629 "session_id": "sess-abc",
2630 });
2631 match classify(&v) {
2632 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2633 other => panic!("expected SystemInit, got {other:?}"),
2634 }
2635 }
2636
2637 #[test]
2638 fn classify_system_without_init_subtype_is_other() {
2639 let v = json!({ "type": "system", "subtype": "compaction" });
2640 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2641 }
2642
2643 #[test]
2644 fn classify_system_init_without_session_id_is_other() {
2645 let v = json!({ "type": "system", "subtype": "init" });
2646 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2647 }
2648
2649 #[test]
2650 fn classify_assistant_stream_event_user() {
2651 assert!(matches!(
2652 classify(&json!({ "type": "assistant" })),
2653 InboundEvent::Assistant(_)
2654 ));
2655 assert!(matches!(
2656 classify(&json!({ "type": "stream_event" })),
2657 InboundEvent::StreamEvent(_)
2658 ));
2659 assert!(matches!(
2660 classify(&json!({ "type": "user" })),
2661 InboundEvent::User(_)
2662 ));
2663 }
2664
2665 #[test]
2666 fn classify_unknown_type_is_other() {
2667 assert!(matches!(
2668 classify(&json!({ "type": "control_request" })),
2669 InboundEvent::Other(_)
2670 ));
2671 assert!(matches!(
2672 classify(&json!({ "type": "future_thing" })),
2673 InboundEvent::Other(_)
2674 ));
2675 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2676 }
2677
2678 #[test]
2679 fn build_args_does_not_emit_subscriber_capacity_flag() {
2680 let args = DuplexOptions::default()
2682 .subscriber_capacity(64)
2683 .build_args();
2684 assert!(!args.iter().any(|a| a.contains("subscriber")));
2685 assert!(!args.iter().any(|a| a.contains("capacity")));
2686 }
2687
2688 #[test]
2689 fn build_args_includes_permission_prompt_tool_when_handler_set() {
2690 let handler = PermissionHandler::new(|_req| async move {
2691 PermissionDecision::Allow {
2692 updated_input: None,
2693 }
2694 });
2695 let args = DuplexOptions::default().on_permission(handler).build_args();
2696 assert!(
2697 args.windows(2)
2698 .any(|w| w == ["--permission-prompt-tool", "stdio"])
2699 );
2700 }
2701
2702 #[test]
2703 fn build_args_omits_permission_prompt_tool_without_handler() {
2704 let args = DuplexOptions::default().build_args();
2705 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2706 }
2707
2708 #[test]
2709 fn build_args_emits_permission_mode_flag() {
2710 let args = DuplexOptions::default()
2711 .permission_mode(PermissionMode::AcceptEdits)
2712 .build_args();
2713 assert!(
2714 args.windows(2)
2715 .any(|w| w == ["--permission-mode", "acceptEdits"]),
2716 "missing --permission-mode acceptEdits in {args:?}"
2717 );
2718 }
2719
2720 #[test]
2721 fn build_args_emits_plan_mode() {
2722 let args = DuplexOptions::default()
2723 .permission_mode(PermissionMode::Plan)
2724 .build_args();
2725 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2726 }
2727
2728 #[test]
2729 fn build_args_omits_permission_mode_by_default() {
2730 let args = DuplexOptions::default().build_args();
2731 assert!(!args.iter().any(|a| a == "--permission-mode"));
2732 }
2733
2734 #[test]
2735 fn build_args_emits_dangerously_skip_permissions_flag() {
2736 let args = DuplexOptions::default()
2737 .dangerously_skip_permissions()
2738 .build_args();
2739 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
2740 }
2741
2742 #[test]
2743 fn build_args_omits_dangerously_skip_by_default() {
2744 let args = DuplexOptions::default().build_args();
2745 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
2746 }
2747
2748 #[test]
2749 fn parse_permission_request_extracts_fields() {
2750 let msg = json!({
2751 "type": "control_request",
2752 "request_id": "req-1",
2753 "request": {
2754 "subtype": "can_use_tool",
2755 "tool_name": "Bash",
2756 "input": { "command": "ls" }
2757 }
2758 });
2759 let req = parse_permission_request(&msg).expect("permission request");
2760 assert_eq!(req.request_id, "req-1");
2761 assert_eq!(req.tool_name, "Bash");
2762 assert_eq!(req.input, json!({ "command": "ls" }));
2763 assert_eq!(
2764 req.raw.get("subtype").and_then(Value::as_str),
2765 Some("can_use_tool")
2766 );
2767 }
2768
2769 #[test]
2770 fn parse_permission_request_returns_none_when_missing_request_id() {
2771 let msg = json!({
2772 "type": "control_request",
2773 "request": {
2774 "subtype": "can_use_tool",
2775 "tool_name": "Bash",
2776 }
2777 });
2778 assert!(parse_permission_request(&msg).is_none());
2779 }
2780
2781 #[test]
2782 fn parse_permission_request_returns_none_when_missing_tool_name() {
2783 let msg = json!({
2784 "type": "control_request",
2785 "request_id": "req-1",
2786 "request": { "subtype": "can_use_tool" }
2787 });
2788 assert!(parse_permission_request(&msg).is_none());
2789 }
2790
2791 #[test]
2792 fn parse_permission_request_handles_missing_input() {
2793 let msg = json!({
2794 "type": "control_request",
2795 "request_id": "req-1",
2796 "request": {
2797 "subtype": "can_use_tool",
2798 "tool_name": "Bash",
2799 }
2800 });
2801 let req = parse_permission_request(&msg).expect("request");
2802 assert_eq!(req.input, Value::Null);
2803 }
2804
2805 #[test]
2806 fn handle_inbound_returns_permission_for_can_use_tool() {
2807 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2808 let (events_tx, _events_rx) = broadcast::channel(16);
2809 let mut pending = Some((tx, Vec::new()));
2810 let action = handle_inbound(
2811 json!({
2812 "type": "control_request",
2813 "request_id": "req-1",
2814 "request": {
2815 "subtype": "can_use_tool",
2816 "tool_name": "Bash",
2817 "input": { "command": "ls" }
2818 }
2819 }),
2820 &mut pending,
2821 &events_tx,
2822 );
2823 match action {
2824 InboundAction::Permission(req) => {
2825 assert_eq!(req.request_id, "req-1");
2826 assert_eq!(req.tool_name, "Bash");
2827 }
2828 InboundAction::None | InboundAction::ControlResponse { .. } => {
2829 panic!("expected Permission action");
2830 }
2831 }
2832 let (_, events) = pending.as_ref().unwrap();
2834 assert_eq!(events.len(), 1);
2835 }
2836
2837 #[test]
2838 fn handle_inbound_treats_unknown_control_request_as_other() {
2839 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2840 let (events_tx, mut events_rx) = broadcast::channel(16);
2841 let mut pending = Some((tx, Vec::new()));
2842 let action = handle_inbound(
2843 json!({
2844 "type": "control_request",
2845 "request_id": "req-2",
2846 "request": { "subtype": "future_subtype" }
2847 }),
2848 &mut pending,
2849 &events_tx,
2850 );
2851 assert!(matches!(action, InboundAction::None));
2852 let event = events_rx.try_recv().expect("broadcast");
2853 assert!(matches!(event, InboundEvent::Other(_)));
2854 }
2855
2856 #[tokio::test]
2857 async fn permission_handler_invokes_closure_async() {
2858 let handler = PermissionHandler::new(|req| async move {
2859 if req.tool_name == "Bash" {
2860 PermissionDecision::Deny {
2861 message: "no bash".into(),
2862 }
2863 } else {
2864 PermissionDecision::Allow {
2865 updated_input: None,
2866 }
2867 }
2868 });
2869 let req = PermissionRequest {
2870 request_id: "r1".into(),
2871 tool_name: "Bash".into(),
2872 input: Value::Null,
2873 raw: Value::Null,
2874 };
2875 match handler.invoke(req).await {
2876 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
2877 other => panic!("expected Deny, got {other:?}"),
2878 }
2879 }
2880
2881 #[test]
2882 fn parse_control_response_extracts_success() {
2883 let msg = json!({
2884 "type": "control_response",
2885 "response": {
2886 "request_id": "interrupt-1",
2887 "subtype": "success",
2888 "response": {}
2889 }
2890 });
2891 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2892 assert_eq!(id, "interrupt-1");
2893 assert!(outcome.is_ok());
2894 }
2895
2896 #[test]
2897 fn parse_control_response_extracts_error_with_message() {
2898 let msg = json!({
2899 "type": "control_response",
2900 "response": {
2901 "request_id": "interrupt-2",
2902 "subtype": "error",
2903 "error": "no turn in flight"
2904 }
2905 });
2906 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2907 assert_eq!(id, "interrupt-2");
2908 match outcome {
2909 Err(Error::DuplexControlFailed { message }) => {
2910 assert_eq!(message, "no turn in flight");
2911 }
2912 other => panic!("expected DuplexControlFailed, got {other:?}"),
2913 }
2914 }
2915
2916 #[test]
2917 fn parse_control_response_returns_none_on_missing_request_id() {
2918 let msg = json!({
2919 "type": "control_response",
2920 "response": { "subtype": "success" }
2921 });
2922 assert!(parse_control_response(&msg).is_none());
2923 }
2924
2925 #[test]
2926 fn parse_control_response_returns_none_on_unknown_subtype() {
2927 let msg = json!({
2928 "type": "control_response",
2929 "response": { "request_id": "x", "subtype": "future_subtype" }
2930 });
2931 assert!(parse_control_response(&msg).is_none());
2932 }
2933
2934 #[test]
2935 fn handle_inbound_returns_control_response_action() {
2936 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2937 let (events_tx, _events_rx) = broadcast::channel(16);
2938 let mut pending = Some((tx, Vec::new()));
2939 let action = handle_inbound(
2940 json!({
2941 "type": "control_response",
2942 "response": {
2943 "request_id": "interrupt-1",
2944 "subtype": "success",
2945 "response": {}
2946 }
2947 }),
2948 &mut pending,
2949 &events_tx,
2950 );
2951 match action {
2952 InboundAction::ControlResponse {
2953 request_id,
2954 outcome,
2955 } => {
2956 assert_eq!(request_id, "interrupt-1");
2957 assert!(outcome.is_ok());
2958 }
2959 InboundAction::None | InboundAction::Permission(_) => {
2960 panic!("expected ControlResponse action");
2961 }
2962 }
2963 }
2964
2965 #[test]
2966 fn handle_inbound_treats_malformed_control_response_as_other() {
2967 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2968 let (events_tx, mut events_rx) = broadcast::channel(16);
2969 let mut pending = Some((tx, Vec::new()));
2970 let action = handle_inbound(
2971 json!({
2972 "type": "control_response",
2973 "response": { "subtype": "success" }
2974 }),
2975 &mut pending,
2976 &events_tx,
2977 );
2978 assert!(matches!(action, InboundAction::None));
2979 let event = events_rx.try_recv().expect("broadcast");
2980 assert!(matches!(event, InboundEvent::Other(_)));
2981 }
2982
2983 #[tokio::test]
2984 async fn permission_handler_clones_arc() {
2985 let handler = PermissionHandler::new(|_req| async move {
2986 PermissionDecision::Allow {
2987 updated_input: None,
2988 }
2989 });
2990 let cloned = handler.clone();
2991 let req = PermissionRequest {
2992 request_id: "r1".into(),
2993 tool_name: "Read".into(),
2994 input: Value::Null,
2995 raw: Value::Null,
2996 };
2997 let _ = handler.invoke(req.clone()).await;
2999 let _ = cloned.invoke(req).await;
3000 }
3001
3002 fn fake_session(
3009 initial: SessionExitStatus,
3010 ) -> (
3011 DuplexSession,
3012 watch::Sender<SessionExitStatus>,
3013 oneshot::Sender<()>,
3014 ) {
3015 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
3016 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
3017 let (exit_tx, exit_rx) = watch::channel(initial);
3018 let (stop_tx, stop_rx) = oneshot::channel::<()>();
3019
3020 let join = tokio::spawn(async move {
3021 let _outbound_rx = outbound_rx;
3022 let _ = stop_rx.await;
3023 Ok::<(), Error>(())
3024 });
3025
3026 let session = DuplexSession {
3027 outbound_tx,
3028 events_tx,
3029 exit_rx,
3030 join,
3031 };
3032 (session, exit_tx, stop_tx)
3033 }
3034
3035 #[tokio::test]
3036 async fn is_alive_true_while_running() {
3037 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3038 assert!(session.is_alive());
3039 }
3040
3041 #[tokio::test]
3042 async fn is_alive_false_after_completed() {
3043 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3044 exit_tx.send(SessionExitStatus::Completed).unwrap();
3045 assert!(!session.is_alive());
3046 }
3047
3048 #[tokio::test]
3049 async fn is_alive_false_after_failed() {
3050 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3051 exit_tx
3052 .send(SessionExitStatus::Failed("boom".into()))
3053 .unwrap();
3054 assert!(!session.is_alive());
3055 }
3056
3057 #[tokio::test]
3058 async fn exit_status_reports_running_initially() {
3059 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3060 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
3061 }
3062
3063 #[tokio::test]
3064 async fn exit_status_reflects_completed() {
3065 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3066 exit_tx.send(SessionExitStatus::Completed).unwrap();
3067 assert!(matches!(
3068 session.exit_status(),
3069 SessionExitStatus::Completed
3070 ));
3071 }
3072
3073 #[tokio::test]
3074 async fn exit_status_reflects_failed_with_message() {
3075 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3076 exit_tx
3077 .send(SessionExitStatus::Failed("oh no".into()))
3078 .unwrap();
3079 match session.exit_status() {
3080 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
3081 other => panic!("expected Failed, got {other:?}"),
3082 }
3083 }
3084
3085 #[tokio::test]
3086 async fn wait_for_exit_returns_immediately_when_already_terminal() {
3087 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3088 exit_tx.send(SessionExitStatus::Completed).unwrap();
3089 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
3090 .await
3091 .expect("wait_for_exit should not block when already terminal");
3092 assert!(matches!(status, SessionExitStatus::Completed));
3093 }
3094
3095 #[tokio::test]
3096 async fn wait_for_exit_blocks_until_state_transitions() {
3097 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3098
3099 let waiter = async { session.wait_for_exit().await };
3100 let driver = async {
3101 tokio::time::sleep(Duration::from_millis(20)).await;
3102 exit_tx.send(SessionExitStatus::Completed).unwrap();
3103 };
3104 let (status, ()) = tokio::join!(waiter, driver);
3105 assert!(matches!(status, SessionExitStatus::Completed));
3106 }
3107
3108 #[tokio::test]
3109 async fn wait_for_exit_supports_multiple_observers() {
3110 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3111
3112 let waiter1 = async { session.wait_for_exit().await };
3113 let waiter2 = async { session.wait_for_exit().await };
3114 let driver = async {
3115 tokio::time::sleep(Duration::from_millis(20)).await;
3116 exit_tx
3117 .send(SessionExitStatus::Failed("crash".into()))
3118 .unwrap();
3119 };
3120 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
3121 match s1 {
3122 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3123 other => panic!("waiter1 expected Failed, got {other:?}"),
3124 }
3125 match s2 {
3126 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3127 other => panic!("waiter2 expected Failed, got {other:?}"),
3128 }
3129 }
3130
3131 #[tokio::test]
3132 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
3133 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3137 let waiter = async { session.wait_for_exit().await };
3138 let driver = async {
3139 tokio::time::sleep(Duration::from_millis(20)).await;
3140 drop(exit_tx);
3141 };
3142 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
3143 tokio::join!(waiter, driver)
3144 })
3145 .await
3146 .expect("wait_for_exit must not hang when sender is dropped");
3147 assert!(matches!(status, SessionExitStatus::Running));
3148 }
3149}